Question
Inspect Rust Macro Expansion with cargo expand and rustc
Question
I have a Rust compile error produced while expanding a recursive macro:
<mdo macros>:6:19: 6:50 error: cannot move out of captured outer variable in an `FnMut` closure
<mdo macros>:6 bind ( $ e , move | $ p | mdo ! { $ ( $ t ) * } ) ) ; (
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
<mdo macros>:1:1: 14:36 note: in expansion of mdo!
<mdo macros>:6:27: 6:50 note: expansion site
src/parser.rs:30:42: 37:11 note: expansion site
Because the macro is recursive, it is difficult to determine which generated code caused the error. The locations also appear to refer to the macro definition instead of my source file.
How can I view the expanded macro output? Is there a rustc or Cargo command that can print the generated Rust code?
Short Answer
Rust macros generate Rust code before type checking and borrow checking occur. To debug an error in generated code, inspect the expansion with cargo expand or, on nightly Rust, use rustc's -Zunpretty=expanded option. The expanded output helps connect a compiler error inside a macro to the code written at the macro call site.
Concept
Declarative Rust macros (macro_rules!) transform token patterns into new Rust source-like code. The compiler then parses, type-checks, and borrow-checks that generated code.
This means an error can originate from code that you did not write directly. For example, a macro may generate a closure, a match, several nested function calls, or recursive macro invocations. The compiler's diagnostic may therefore mention a location such as <mdo macros>:6, which refers to tokens in the macro definition or its generated expansion.
Macro expansion is especially useful when an error mentions generated closures or ownership rules, such as:
cannot move out of captured outer variable- a lifetime error in code you cannot see
- an unexpected type mismatch from a macro invocation
- missing names or trait implementations in generated code
The most convenient inspection tool is cargo-expand. It prints the Rust code after macro expansion in a readable form. For lower-level compiler output, nightly Rust provides -Zunpretty=expanded.
Expansion output is a debugging aid, not necessarily code to copy into production. It may include compiler-generated attributes, fully qualified paths, and expanded code from dependencies or standard macros.
Mental Model
Think of a macro as a code-producing template machine.
You place a small instruction into the machine:
mdo! {
// your input
}
The machine produces a much larger Rust program. Rust's borrow checker examines the produced program, not the short instruction you originally typed.
When an error points inside the machine, inspecting macro expansion is like opening the machine's output tray. You can see the actual closure, variable binding, or function call that Rust is checking.
Syntax and Examples
Install the expansion tool once:
cargo install cargo-expand
Expand the default target in the current Cargo project:
cargo expand
Expand a library target explicitly:
cargo expand --lib
Expand a particular item when the crate is large:
cargo expand parser::parse_input
For example, consider this macro:
macro_rules! log_and_return {
($value:expr) => {{
println!("value = {:?}", $value);
$value
}};
}
fn main() {
let number = log_and_return!(42);
println!("{number}");
}
A simplified expansion looks like this:
fn main() {
let number = {
println!("value = {:?}", 42);
42
};
println!("{number}");
}
The macro call disappears and is replaced by its generated block. If the generated block had an ownership or type error, this is the code you would investigate.
On a nightly toolchain, Cargo can invoke rustc directly with the compiler expansion printer:
cargo +nightly rustc --lib -- -Zunpretty=expanded > expanded.rs
Then open expanded.rs in an editor. The -Z option is unstable, so it requires nightly Rust. In most projects, cargo expand is the clearer and more convenient starting point.
Step by Step Execution
Consider a macro that creates a move closure:
macro_rules! make_printer {
($text:expr) => {
move || println!("{}", $text)
};
}
fn main() {
let message = String::from("hello");
let print_message = make_printer!(message);
print_message();
}
The macro call is:
make_printer!(message)
After expansion, the relevant code is approximately:
fn main() {
let message = String::from("hello");
let print_message = move || println!("{}", message);
print_message();
}
Step by step:
messageowns aString.- The macro generates a
moveclosure. - A
moveclosure capturesmessageby value, transferring ownership into the closure. - Calling
print_message()is valid because the closure ownsmessage. - If surrounding macro-generated code tries to move
messageagain, or requires the closure to implement a trait incompatible with consuming its capture, Rust reports an ownership error.
For a recursive macro, run cargo expand, search for a distinctive expression from the original invocation, and examine the nested generated closures around it. That usually reveals which recursive level introduces the problematic move.
Real World Use Cases
Macro expansion inspection is useful in several practical situations:
- Debugging ownership errors: Find the generated
moveclosure,async moveblock, or iterator callback that captures a value. - Understanding derive macros: Inspect code generated by derives such as
Debug,Serialize, or custom procedural macros. - Diagnosing framework macros: Web frameworks, ORM libraries, test frameworks, and parser libraries often generate substantial Rust code.
- Checking performance-sensitive code: Confirm whether a macro creates allocations, temporary values, nested closures, or repeated evaluation.
- Learning library APIs: Expand a small macro invocation to understand what API calls a library macro produces.
- Reviewing custom macros: Verify that a macro handles identifiers, expressions, and ownership in the intended way.
Real Codebase Usage
In real Rust projects, developers usually use macro expansion alongside a small reproducible example.
A practical workflow is:
- Reduce the failing macro invocation to the smallest input that still fails.
- Run
cargo expand --libor select the relevant binary/example target. - Redirect output to a file when it is large:
cargo expand --lib > expanded.rs
- Search
expanded.rsfor a unique function name, literal, or identifier from the failing call site. - Locate generated closures and determine whether a value is borrowed, copied, or moved.
- Fix either the macro invocation or the macro definition, then rerun
cargo check.
For ownership-related errors, common fixes in real code include:
- Borrowing a value instead of moving it.
- Cloning an owned value when independent ownership is genuinely required.
- Moving a value only into a closure that is called once (
FnOnce), rather than a reusableFnMutorFnclosure. - Changing a macro expansion so a value is bound outside repeated closures.
- Using an early binding or a reference to avoid repeatedly capturing the same outer value.
If diagnostics remain hard to follow, nightly Rust can provide a more detailed macro expansion chain:
cargo +nightly check -- -Zmacro-backtrace
Like other -Z flags, this is nightly-only.
Common Mistakes
Expecting error locations to match only your source
Macro diagnostics may point to the macro definition, an expansion site, and finally your invocation. Read the complete diagnostic chain, especially the notes mentioning expansion site.
Treating the generated code as manually written source
Expansion output can contain internal-looking paths, compiler attributes, and formatting that is not intended for direct editing. Use it to understand the transformation, then change your original macro call or macro definition.
Using a nightly-only command on stable Rust
This command requires nightly:
cargo rustc -- -Zunpretty=expanded
On stable Rust, install and use cargo-expand instead:
cargo install cargo-expand
cargo expand
Expanding the wrong Cargo target
A package can have a library, multiple binaries, examples, integration tests, and benches. If the expected invocation is absent, select the target:
cargo expand --bin my_tool
cargo expand --example demo
cargo expand --test parser_tests
Assuming move means “safe to use everywhere”
move transfers captured values into a closure. If generated code needs to call that closure more than once, it cannot consume the same captured value on every call.
Broken pattern:
let name = String::from("Ada");
let consume = move || drop(name);
consume();
consume(); // error: the closure can only be called once
Avoid this by borrowing, cloning when appropriate, or designing the closure to consume the value only once.
Looking only at the outermost macro call
Recursive macros may generate several layers of expansion. Search the expanded output for the first generated closure or binding near the original expression, then work inward.
Comparisons
| Tool or technique | Best use | Availability | Output |
|---|---|---|---|
cargo expand | Everyday inspection of macro-generated Rust | Installable Cargo subcommand | Readable expanded Rust |
cargo +nightly rustc -- -Zunpretty=expanded | Direct compiler-level expansion output | Nightly only | Expanded Rust sent to standard output |
-Zmacro-backtrace | More detail about where nested macro expansions came from | Nightly only | Better diagnostic backtraces, not full source |
| Reading the macro definition | Understanding intended transformation rules | Always | Macro patterns and templates |
| Reducing the failing invocation | Isolating the exact problematic input | Always | A smaller, easier-to-expand example |
cargo expand answers: What code did this macro generate?
A macro backtrace answers: Which sequence of macro invocations led to this diagnostic?
Both are valuable: use expansion to inspect generated ownership behavior and a backtrace to identify the recursive path that generated it.
Cheat Sheet
# Install the macro expansion tool
cargo install cargo-expand
# Expand the usual target
cargo expand
# Expand a library target
cargo expand --lib
# Expand a named binary
cargo expand --bin my_binary
# Expand an example, test, or item
cargo expand --example demo
cargo expand --test integration_test
cargo expand module::item
# Save expansion for searching in an editor
cargo expand --lib > expanded.rs
# Nightly: use rustc's expansion printer
cargo +nightly rustc --lib -- -Zunpretty=expanded > expanded.rs
# Nightly: show a detailed macro expansion backtrace in diagnostics
cargo +nightly check -- -Zmacro-backtrace
Key rules:
- Rust checks the code produced by a macro.
- Macro diagnostic spans can refer to both the definition and the call site.
-Z...compiler flags are unstable and require nightly.- Search expanded output for a unique identifier from the failing invocation.
- For closure errors, inspect captures and determine whether each value is borrowed, copied, or moved.
FAQ
How do I print expanded Rust macros?
Install cargo-expand and run cargo expand in the Cargo project directory. Redirect its output to a file if the crate is large.
Does cargo expand work for recursive macros?
Yes. It prints the final expanded form after recursive expansion, making nested generated code visible.
Why does a macro error mention <macro name>:line instead of my file?
The compiler found the problematic generated tokens in the macro definition or expansion. Diagnostic notes usually include an expansion site that leads back to your source invocation.
Can stable rustc use -Zunpretty=expanded?
No. -Z compiler options are unstable and require a nightly Rust toolchain. Use cargo expand for the normal workflow.
How can I expand only one binary or test?
Select the target with Cargo options such as cargo expand --bin name, cargo expand --test name, or cargo expand --example name.
What does “cannot move out of captured outer variable in an FnMut closure” mean?
A generated closure captured a value from its surrounding scope, but code inside the closure tries to move that value out. An FnMut closure may be called repeatedly, so it generally cannot consume the same captured value each time.
Should I edit the output from cargo expand?
Usually no. Treat it as a diagnostic view. Make the correction in the original Rust source or macro definition.
Mini Project
Description
Build a small Rust program containing a macro that generates a closure. First inspect the expansion, then use that generated code to understand an ownership error caused by moving a captured String. This mirrors the workflow used to debug closure errors hidden by larger macros.
Goal
Use cargo expand to inspect macro-generated closure code and correct an ownership problem by cloning the captured value.
Requirements
Create a Cargo binary project with a macro that returns a closure.
Invoke the macro with an owned String.
Attempt to call the generated closure twice and observe the ownership error.
Use cargo expand to inspect the generated closure.
Modify the macro so the returned closure can be called more than once.
Keep learning
Related questions
Absolute Values in Rust: Using the abs() Method
Learn how to calculate absolute values in modern Rust with abs(), handle signed integer overflow, and choose safer alternatives.
Accepting Iterators in Rust Functions with impl Iterator
Learn how to write Rust functions that accept HashMap iterators using generic bounds or impl Iterator, avoiding trait object and lifetime errors.
Access Enum Variant Values in Rust with Pattern Matching
Learn how to extract and print values stored in Rust enum variants using match and if let pattern matching, with practical examples.