How to debug macros

Debug Rust macros by using rustc flags or RUSTFLAGS to expand and inspect the generated code before compilation.

When the compiler points to a line that doesn't exist

You write a macro. It looks clean. You compile. The compiler screams about a type mismatch on line 42. You look at line 42. It's the macro call. You look at the macro definition. The syntax is valid. The logic seems sound. You're stuck staring at tokens that refuse to make sense.

The macro is a black box. You're feeding it input, but the error message is about the output. The compiler never type-checks your macro definition. It type-checks the code the macro generates. If that generated code is wrong, the error points to the result, not the recipe.

To fix the macro, you need to crack the box open. You need to see the code the macro produces before the compiler tries to run it.

Macros are code generators, not magic

Rust macros transform tokens into other tokens. macro_rules! macros match patterns and replace them with template code. Procedural macros take an input stream of tokens and return a new stream. In both cases, the compiler sees the result.

Think of a macro like a stencil. You hold the stencil over paper and spray paint. If the image on the paper is crooked, checking the stencil helps, but you really need to look at the ink on the paper. The expansion is the ink. It's the concrete source code that exists after the macro runs but before type checking begins.

When you debug a macro, you are debugging the expansion. The error message describes a problem in the expansion. Your job is to trace that problem back to the macro definition.

Minimal example: a macro that shadows itself

Here is a macro that tries to generate a function but makes a subtle mistake with identifiers.

/// Generates a function that prints a greeting.
macro_rules! greet {
    ($name:ident) => {
        fn $name() {
            // Bug: $name refers to the function identifier, not a string.
            // The macro tries to print the function itself.
            println!("Hello, {}", $name);
        }
    };
}

fn main() {
    greet!(say_hi);
    say_hi();
}

This code fails to compile. The compiler rejects it with E0277 (the trait bound fn() {say_hi}: std::fmt::Display is not satisfied). The error points to the println! line inside the macro. You might look at $name and think it should work because you passed say_hi. The error message doesn't explain that $name in the body resolves to the function identifier, which is not a string and doesn't implement Display.

You need the expansion to see what the compiler actually sees.

Expanding macros with rustc

The Rust compiler has a built-in flag to show macro expansions. It requires the nightly toolchain because the flag is unstable.

Run this command to expand macros in a single file:

rustc -Z macro-backtrace --pretty=expanded your_file.rs

The --pretty=expanded flag tells rustc to print the fully expanded source code to stdout instead of compiling it. The -Z macro-backtrace flag adds comments to the output showing where each piece of code came from. Without it, the expansion is a wall of text with no provenance.

If you are using Cargo, you pass the flag via RUSTFLAGS:

RUSTFLAGS="-Z macro-backtrace --pretty=expanded" cargo build

This sets the flags for the entire build. Every crate gets expanded. The output is massive. Use this only when you need to see the expansion of a dependency or when you cannot install external tools.

The expansion of the greet macro reveals the bug immediately:

fn say_hi() {
    // ~ expanded from macro `greet`
    println!("Hello, {}", say_hi);
}

The expansion shows say_hi as the second argument to println!. It's the function identifier. The compiler is right. The macro needs to use stringify!($name) to get the name as a string.

Don't guess the tokens. Expand them.

Realistic example: debugging a repetition macro

Macros often use repetition syntax like $(...),*. Bugs in repetition are hard to spot because the macro definition looks correct, but the generated code has syntax errors or type mismatches.

Consider a macro that builds a Vec of options from a list of values. It should wrap each value in Some.

/// Creates a Vec<Option<T>> from a list of values.
macro_rules! vec_options {
    ($($val:expr),*) => {
        vec![
            $($val),*
        ]
    };
}

fn main() {
    let opts = vec_options!(1, 2, 3);
    println!("{:?}", opts);
}

This compiles, but the result is wrong. The macro generates vec![1, 2, 3], which is a Vec<i32>, not a Vec<Option<i32>>. The macro forgot to wrap the values. If you expected Option, your downstream code will fail with E0308 (mismatched types) when you try to use opts as Vec<Option<i32>>.

The expansion shows the generated code:

let opts = vec![
    // ~ expanded from macro `vec_options`
    1, 2, 3
];

The expansion makes it obvious that Some is missing. The fix is to wrap each repetition:

macro_rules! vec_options {
    ($($val:expr),*) => {
        vec![
            $(Some($val)),*
        ]
    };
}

Now the expansion produces vec![Some(1), Some(2), Some(3)]. The types match.

The compiler sees the expansion. If the expansion is wrong, your macro is wrong. Fix the output.

Pitfalls and compiler errors

Macro debugging has quirks. The tools are powerful but can be noisy.

Nightly only

The --pretty=expanded flag is unstable. It only works on the nightly toolchain. If you are on stable, you cannot use rustc flags to expand macros. You need a helper crate.

The community convention is to use cargo expand. It is a separate crate that wraps the unstable flags and provides a cleaner interface. Install it with cargo install cargo-expand. Then run cargo expand in your project. It works on stable because the crate itself is compiled on nightly and ships the expansion logic.

Use cargo expand for daily work. It handles Cargo metadata, targets specific binaries or libraries, and formats the output nicely.

Hygiene and variable names

Rust macros are hygienic. Variables defined inside a macro do not leak into the caller's scope, and vice versa. This prevents accidental name collisions.

When you look at the expansion, hygiene can be confusing. Some tools show variables with mangled names like x$0 or x$1. Other tools show the original name but rely on comments to indicate scope. If you see a variable in the expansion that looks like it should conflict but doesn't, hygiene is protecting you.

The expansion shows the code structure, but it doesn't always show the scope boundaries. Trust the compiler on hygiene. If the expansion compiles, the scopes are correct.

Macro backtraces

The -Z macro-backtrace flag is different from --pretty=expanded. It does not show the code. It shows the stack of macro invocations in error messages.

When you have nested macros, an error might originate deep inside a chain of expansions. Without backtraces, the error points to the outermost macro call. With backtraces, the error message includes a list of macros that were invoked, helping you trace the bug back to the source.

Use -Z macro-backtrace when the error message points to a macro call and you need to see the stack of macro invocations. Combine it with --pretty=expanded for full visibility.

Huge output

Expanding all macros in a large project produces megabytes of text. The output includes expansions for the standard library and dependencies. It's easy to get lost.

Focus on your crate. Use cargo expand --lib or cargo expand --bin name to target specific artifacts. Search for your macro name in the output. Most editors handle large files well, but grep is your friend.

Don't fight the output. Filter it to what matters.

Decision: when to use which tool

Use cargo expand when you want a clean, readable expansion of your crate with minimal setup. It is the community standard for macro debugging. It works with Cargo targets and handles formatting.

Use rustc --pretty=expanded when you are debugging a single file outside of Cargo or when you cannot install external crates. It requires nightly and produces raw output.

Use -Z macro-backtrace when error messages point to macro calls and you need to see the invocation stack. It helps trace bugs through nested macros. Combine it with expansion flags for full context.

Use RUSTFLAGS to pass expansion flags to Cargo when you need to expand dependencies or when cargo expand is not available. Be aware that RUSTFLAGS affects the entire build, including dependencies, which can slow down compilation.

Trust the expansion. The compiler is always right about the generated code. If the expansion looks wrong, the macro is wrong. Fix the macro until the expansion matches your intent.

Where to go next