Question

Inspect Rust Macro Expansion with cargo expand and rustc

rustdebuggingrust-macrosrust-proc-macrosrust-decl-macros

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.

Take Quiz

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:

  1. message owns a String.
  2. The macro generates a move closure.
  3. A move closure captures message by value, transferring ownership into the closure.
  4. Calling print_message() is valid because the closure owns message.
  5. If surrounding macro-generated code tries to move message again, 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 move closure, async move block, 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.
Take Quiz

Real Codebase Usage

In real Rust projects, developers usually use macro expansion alongside a small reproducible example.

A practical workflow is:

  1. Reduce the failing macro invocation to the smallest input that still fails.
  2. Run cargo expand --lib or select the relevant binary/example target.
  3. Redirect output to a file when it is large:
cargo expand --lib > expanded.rs
  1. Search expanded.rs for a unique function name, literal, or identifier from the failing call site.
  2. Locate generated closures and determine whether a value is borrowed, copied, or moved.
  3. 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 reusable FnMut or Fn closure.
  • 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 techniqueBest useAvailabilityOutput
cargo expandEveryday inspection of macro-generated RustInstallable Cargo subcommandReadable expanded Rust
cargo +nightly rustc -- -Zunpretty=expandedDirect compiler-level expansion outputNightly onlyExpanded Rust sent to standard output
-Zmacro-backtraceMore detail about where nested macro expansions came fromNightly onlyBetter diagnostic backtraces, not full source
Reading the macro definitionUnderstanding intended transformation rulesAlwaysMacro patterns and templates
Reducing the failing invocationIsolating the exact problematic inputAlwaysA 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.

Take Quiz

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.

Take Quiz

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.

Take Quiz

Keep learning

Related questions

View more