How to Debug Proc Macro Compilation Errors

Use RUSTFLAGS="-Z macro-backtrace" to view expanded proc macro code and debug compilation errors.

The invisible layer between your code and the compiler

You write a procedural macro. It compiles without warnings. You attach it to a struct. The compiler immediately rejects your code with a cryptic message about unexpected tokens or mismatched types. The error points to your attribute line, not your macro source file. You stare at the macro code, which looks perfectly valid, and wonder why the compiler is angry.

The problem is that procedural macros run in a separate process during compilation. They receive your source code as a stream of tokens, transform it, and hand the result back to the compiler. The compiler never sees your macro source. It only sees the generated tokens. When those tokens are malformed, the compiler diagnoses the output, not the factory that produced it. Debugging a proc macro means intercepting that handoff and inspecting what actually crossed the boundary.

Think of a proc macro like a custom assembly line in a factory. You hand it raw materials. It runs its machinery. It hands back a finished part. If the part has a crack, the quality inspector doesn't look at the assembly line's blueprints. They just reject the part. To fix the line, you need to watch the machinery run and examine the parts it produces.

Peeking inside the black box with macro backtraces

When a macro panics during expansion, the compiler usually swallows the panic and returns a generic error. The actual stack trace lives inside the macro process, hidden from your terminal. Rust provides an unstable flag to force that trace into the surface.

// Example macro that panics on unexpected input
use proc_macro::TokenStream;

#[proc_macro_derive(MyDebug)]
pub fn my_debug(_input: TokenStream) -> TokenStream {
    // Simulate a logic error during expansion
    panic!("Macro encountered an unsupported type configuration");
}

Run your build with the unstable flag to surface the panic:

RUSTFLAGS="-Z macro-backtrace" cargo build

The compiler now prints the full Rust stack trace from inside the macro process. You will see the exact line in your macro code that triggered the panic, along with the call chain through syn or quote. This flag does not show the generated tokens. It only shows where the macro code itself crashed.

Unstable flags require the nightly toolchain. Switch to nightly with rustup default nightly before using -Z flags. The community treats -Z macro-backtrace as a temporary debugging switch, not a permanent build configuration. Drop it once the panic is resolved.

Trust the backtrace. It points directly to the line that broke.

Reading the generated tokens

Most macro errors are not panics. They are syntax errors in the output. The macro ran to completion, but the tokens it produced do not form valid Rust. You need to see exactly what the macro handed to the compiler.

The standard tool for this is cargo expand. It runs your crate through the compiler's expansion phase and prints the result to standard output.

cargo install cargo-expand
cargo expand

The output shows your entire crate after all macros have been expanded. Find the struct or function with your attribute. The code immediately following it is what the compiler actually compiles.

// Your source code
#[derive(MyDebug)]
struct Config {
    name: String,
    timeout: u64,
}

// What cargo expand shows (simplified)
struct Config {
    name: String,
    timeout: u64,
}
impl std::fmt::Debug for Config {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Generated implementation
        f.debug_struct("Config")
            .field("name", &self.name)
            .field("timeout", &self.timeout)
            .finish()
    }
}

If the expanded code contains a missing import, a malformed macro call, or a type mismatch, the error becomes obvious. The compiler error you saw earlier now maps directly to a line in the expanded output.

The community convention is to pipe cargo expand through rustfmt or prettyplease for readability. Raw token trees can be dense. Formatted output makes structural errors stand out.

Stop guessing what your macro produces. Read the output.

Tracing macro execution step by step

Sometimes the macro generates valid syntax but the wrong logic. The tokens are correct Rust, but they implement the wrong algorithm. You need to trace the macro's internal state as it parses and transforms input.

Standard println! works inside proc macros. The output goes to standard error during compilation, interleaved with compiler messages. It is simple and effective for tracking control flow.

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(MyDebug)]
pub fn my_debug(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    
    // Log the parsed struct name to stderr
    eprintln!("Parsing derive input for: {}", input.ident);
    
    let name = &input.ident;
    let expanded = quote! {
        impl std::fmt::Debug for #name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                f.debug_struct(stringify!(#name)).finish()
            }
        }
    };
    
    TokenStream::from(expanded)
}

Run cargo build normally. The eprintln! output appears in your terminal before the compiler finishes. You can log field names, attribute values, or intermediate syn structures. This approach works on stable Rust and requires no extra dependencies.

For richer diagnostics, use syn::Error::new. It attaches a custom message to a specific Span, which tells the compiler exactly where in the user's code the problem originated.

use syn::Error;

// Attach an error to the first field's span
let err = Error::new(
    fields.iter().next().unwrap().ident.span(),
    "MyDebug only supports structs with string fields",
);
return err.to_compile_error().into();

The compiler renders this as a standard error pointing to the user's source line, not the macro code. This is the professional standard for macro diagnostics. It turns a confusing expansion failure into a clear, actionable message.

Treat syn::Error as your primary diagnostic tool. Print statements are for development. Span-attached errors are for users.

Common traps and how the compiler reports them

Proc macros introduce a specific set of failure modes. Recognizing them saves hours of debugging.

The most frequent issue is span loss. When you construct tokens manually without preserving spans, the compiler cannot point to the original source location. Errors appear at line 1, column 1, or at the macro call site with zero context. Always use syn and quote instead of manual TokenStream manipulation. They preserve spans automatically.

Another trap is missing imports in generated code. Your macro might generate HashMap::new(), but the calling module never imported std::collections::HashMap. The compiler rejects it with E0433 (failed to resolve). The error points to the macro call, not your macro source. Fix it by generating fully qualified paths like std::collections::HashMap::new() or by emitting use statements inside the macro output.

Hygiene issues appear when your macro introduces identifiers that clash with the caller's scope. If your macro generates a helper function named helper, and the caller already has a helper function, you get E0428 (constant/function redefined). The solution is to use syn::Ident::new with a unique suffix, or to generate the helper inside an anonymous module.

Unstable feature usage triggers E0658. Proc macros sometimes generate code that relies on nightly-only syntax. If your macro outputs #![feature(trait_alias)], the calling crate must also be on nightly. Document this requirement clearly. The compiler will not guess that a macro requires a feature flag.

Temporary value lifetimes cause E0716. If your macro generates a closure that captures a reference to a temporary, the compiler rejects it. This happens when macros generate complex iterator chains or string formatting calls without proper binding. Expand the temporary into a named let binding inside the macro output.

Read the error code. It tells you whether the problem is in your macro logic, your generated syntax, or the caller's environment.

Choosing your debugging tool

Use -Z macro-backtrace when your macro panics during expansion and you need the exact line in your macro source that crashed. Use cargo expand when the macro compiles but produces invalid Rust syntax, and you need to inspect the actual tokens handed to the compiler. Use eprintln! when you are tracing control flow, parsing branches, or intermediate data structures during active development. Use syn::Error::new when you want to provide production-ready diagnostics that point to the user's source code with clear messages. Reach for proc_macro2 and quote for all token manipulation; manual TokenStream construction loses spans and breaks error reporting.

Pick the tool that matches the symptom. Panics get backtraces. Syntax errors get expansion. Logic errors get tracing. User-facing errors get span-attached diagnostics.

Where to go next