How to Debug Macros in Rust (cargo expand)

Use `cargo expand` to inspect the code generated by your macros before compilation, as this reveals the exact syntax the compiler sees and helps identify expansion errors.

When the compiler blames code you didn't write

You write a macro. It looks clean. You run cargo build. The compiler screams about a missing semicolon on line 402. You open your file. It has twelve lines. You stare at the error. It points to a struct definition you never typed. The error message references a type that exists only inside your macro's imagination.

This is the macro black box problem. You are writing code that generates code, but the compiler only sees the generated code. When something breaks, the error points to the output, not the generator. You are left guessing which part of your macro produced the broken syntax.

cargo expand breaks the black box open. It runs the macro expansion phase and shows you the exact Rust source code the compiler receives. You stop guessing. You see the problem directly.

The macro black box

Macros are code generators. You write a template with placeholders. You invoke the template with arguments. The macro engine fills in the placeholders and emits raw Rust tokens. The compiler never sees your macro definition during type checking. It sees the emitted tokens.

Think of a macro like a recipe generator. You give the generator ingredients, and it spits out a full recipe. The chef (the compiler) only reads the final recipe. If the recipe has a typo, the chef complains about the recipe, not the generator. cargo expand lets you read the recipe before the chef starts cooking.

The compiler sees the expansion, not the macro. You should too.

Your first expansion

Install the tool. cargo expand is an external binary, not part of the standard rustc distribution.

cargo install cargo-expand

Create a simple macro to test. This macro generates a struct with a debug print method.

// src/lib.rs

/// Generates a struct with a debug print method.
macro_rules! debug_struct {
    ($name:ident) => {
        struct $name {
            value: i32,
        }

        impl $name {
            /// Prints the internal value to stdout.
            fn print_debug(&self) {
                println!("{:?}", self.value);
            }
        }
    };
}

// Invoke the macro to create MyData.
debug_struct!(MyData);

fn main() {
    let data = MyData { value: 42 };
    data.print_debug();
}

Run cargo expand in your project root.

cargo expand

The tool outputs the fully expanded source code. You will see the struct MyData and impl MyData blocks written out in full. The macro invocation debug_struct!(MyData) is gone. It has been replaced by the concrete code.

If you add a syntax error inside the macro, cargo expand reveals it immediately. Change the macro to miss a comma in the struct definition. Run cargo expand. The output shows the struct with the missing comma. You can see exactly where the syntax breaks without parsing a cryptic compiler error.

Convention note: cargo expand formats the output using the prettyplease crate. The formatting might differ slightly from rustfmt. The structure is identical. Do not rely on whitespace for debugging. Look at the tokens.

The compiler sees the expansion. You should too.

What cargo expand actually does

When you run cargo build, the compiler performs multiple passes. First, it expands all macros. This includes macro_rules! macros and procedural macros. The macro code executes. It produces tokens. Second, the compiler checks types on the expanded tokens. Third, it generates machine code.

cargo expand stops after the first pass. It runs the macro expansion. It collects the output. It prints the result. It does not check types. It does not generate machine code.

This separation is powerful. cargo expand can succeed even if your expanded code has type errors. You can verify the structure of your macro output before worrying about type mismatches. If cargo expand fails, the problem is in the macro logic or syntax, not in the types of the generated code.

There is a subtle trap here. cargo expand executes your macro code. Macros run at compile time. If your macro contains a panic!, cargo expand will panic. You will see a panic message, not expanded code. This is a feature. It tells you that your macro logic is broken before you even look at the output.

If cargo expand panics, your macro logic is broken. Fix the macro, not the tool.

Filtering the noise

Large projects generate megabytes of expanded code. cargo expand on a full crate can output hundreds of thousands of lines. Scrolling through that is useless. You need to isolate the macro you are debugging.

Use the --lib flag to expand only the library crate. Use --bin to expand a specific binary. Use --item to filter by name. The --item flag accepts a regex pattern. It matches function names, struct names, and macro invocations.

# Expand only the library crate.
cargo expand --lib

# Expand only the binary named 'cli'.
cargo expand --bin cli

# Expand only items matching 'my_macro'.
cargo expand --lib --item my_macro

The --item flag is the most useful. It filters the output to show only the expansion of the specified item and its dependencies. If you have a macro called make_parser, run cargo expand --lib --item make_parser. You get the expansion of make_parser and nothing else.

For very large outputs, pipe the result to a file. Editors handle syntax highlighting and search better than terminals.

cargo expand --lib --item my_macro > expanded.rs

Convention note: Add expanded.rs to your .gitignore. Never commit expanded code to version control. It is derived data. It changes every time you change the macro. Commit the macro. Generate the expansion locally when you need it.

Filter aggressively. You want the signal, not the megabytes.

Realistic debugging scenario

Macros often generate complex generic code. A common pattern is a macro that creates a parser for different token types. The macro might generate a match expression with many arms. If you forget a comma or a brace, the error can be hard to trace.

Consider this macro that generates a parser function.

// src/lib.rs

/// Generates a parser function for a specific token type.
/// The parser matches string literals to enum variants.
macro_rules! make_parser {
    ($name:ident, $token:ty) => {
        fn $name(input: &str) -> Option<$token> {
            match input.trim() {
                "start" => Some($token::Start),
                "stop" => Some($token::Stop),
                "reset" => Some($token::Reset),
                _ => None,
            }
        }
    };
}

#[derive(Debug)]
enum Command {
    Start,
    Stop,
    Reset,
}

// Generate the parser for Command.
make_parser!(parse_cmd, Command);

fn main() {
    let cmd = parse_cmd("start");
    println!("{:?}", cmd);
}

Run cargo expand --lib --item parse_cmd. The output shows the parse_cmd function with all the match arms filled in. You can verify that the enum variants are correct. You can check that the return type matches.

Now imagine the macro has a bug. It generates Some($token::Start) without a comma. The compiler error points to the expanded code. cargo expand shows the missing comma immediately. You fix the macro. You run cargo expand again. The comma appears. You run cargo build. The error is gone.

This workflow is faster than guessing. You see the problem. You fix the problem. You verify the fix.

Expand to see syntax. Check to see types. Build to see speed.

Pitfalls and gotchas

cargo expand is a powerful tool, but it has limitations. Understanding these prevents frustration.

cargo expand often requires the nightly toolchain. It uses internal rustc flags to capture the expanded output. If you are on stable Rust, cargo expand might fail with an error about unknown features. Switch to nightly using rustup default nightly. If your project requires stable, you can run cargo expand with nightly temporarily for debugging, then switch back.

# Switch to nightly for debugging.
rustup default nightly

# Run expand.
cargo expand

# Switch back to stable.
rustup default stable

cargo expand does not run type checking. It only expands macros. If your macro generates code with type errors, cargo expand will succeed. You must run cargo check or cargo build to catch type errors. cargo expand verifies syntax and structure. cargo check verifies types. Use both.

cargo expand can fail on projects with complex build scripts. If your build.rs generates code that depends on unstable features, cargo expand might not handle it correctly. In these cases, you may need to inspect the build script output directly or use rustc -Z unpretty=expanded manually.

cargo expand uses prettyplease to format the output. prettyplease is a formatter, not a compiler. It might fail to format code that uses very new syntax or unusual token combinations. If cargo expand outputs garbled text, try upgrading cargo-expand to the latest version. The formatter improves with each release.

If cargo expand panics, your macro logic is broken. Fix the macro, not the tool.

Decision matrix

Choose the right tool for the debugging phase.

Use cargo expand when you need to see the exact syntax your macro generates.

Use cargo expand when compiler errors point to macro-generated code and you cannot trace the source.

Use cargo expand when verifying that a macro produces valid Rust structure before running type checks.

Use cargo expand --item when debugging a specific macro in a large crate.

Use cargo check when you need to catch type errors in the expanded code.

Use cargo test when you need to verify the runtime behavior of macro-generated functions.

Use dbg! inside a macro when you need to inspect values during the expansion phase itself.

Expand first. Check second. Build third.

Where to go next