Metaprogramming Pitfalls: Debugging & Performance
Macros are powerful but dangerous: a single mistake in a macro's code generation can introduce subtle type errors, infinite compile loops, confusing error messages, or bloated binaries. Unlike normal code, macro bugs are hard to debug because the error occurs in generated code, not the macro source. Additionally, procedural macros run at compile time and can significantly impact build performance. This article covers the pitfalls that trip up macro authors—compiler error interpretation, expand debugging, compile-time complexity, edge case handling, and hygiene violations—and provides techniques to avoid or fix them.
Pitfall 1: Confusing Compiler Errors from Generated Code
When a macro generates invalid Rust code, the compiler's error message points to the generated code, not the macro source. This can be maddening because you see errors in code you did not write.
Example: Type Mismatch in Generated Code
// Macro generates type-mismatched code
#[proc_macro_derive(Add)]
pub fn derive_add(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let expanded = quote! {
impl Add for #name {
type Output = i32; // WRONG: assumes all types produce i32
fn add(self, other: Self) -> i32 {
self + other // Type error if Self is not i32
}
}
};
TokenStream::from(expanded)
}
When a user derives this macro on a non-i32 type:
#[derive(Add)]
struct Vec3(f64, f64, f64);
The compiler error is cryptic:
error[E0308]: mismatched types
--> <proc macro>::Add
|
| self + other
| ^^^^^^^^^^^^ expected `i32`, found `f64`
The user has no idea the problem is in the macro.
Solution: Use Span Information and Attribute Markers
To improve error messages, attach errors to the original token and provide context:
#[proc_macro_derive(Add)]
pub fn derive_add(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
// Validate the type is numeric
match &input.data {
Data::Struct(data) => {
match &data.fields {
Fields::Unit => {
let err = syn::Error::new_spanned(
name,
"Add can only be derived for tuple or named structs"
);
return err.to_compile_error().into();
}
_ => {}
}
}
_ => {
let err = syn::Error::new_spanned(
name,
"Add can only be derived for structs"
);
return err.to_compile_error().into();
}
}
// Generate code safely
let expanded = quote! {
impl Add for #name {
type Output = Self;
fn add(self, other: Self) -> Self {
// Implementation details
todo!()
}
}
};
TokenStream::from(expanded)
}
Pitfall 2: Opaque Expansions—Not Knowing What Code Is Generated
The biggest debugging bottleneck is not seeing what code the macro actually generates. The solution is cargo expand.
Using cargo expand
Install and run cargo-expand:
cargo install cargo-expand
cargo expand path::to::item
This prints the expanded code:
$ cargo expand my_module::my_struct
impl Add for MyStruct {
type Output = Self;
fn add(self, other: Self) -> Self {
// Generated implementation
}
}
With full visibility, debugging becomes straightforward.
Enabling Expansion in Tests
For procedural macros, use integration tests with cargo expand:
cargo expand --test integration_test
Pitfall 3: Infinite Recursion or Runaway Compilation
A macro can accidentally trigger infinite expansion or exponential code bloat. This manifests as a hung compiler or out-of-memory crash.
Example: Recursive Macro
// DANGEROUS: Recursive expansion without a base case
macro_rules! expand_forever {
($x:expr) => {
expand_forever!($x + 1) // Infinite recursion!
};
}
expand_forever!(0); // Compiler hangs
Solution: Carefully Limit Recursion
Add base cases and depth limits:
macro_rules! expand_n_times {
// Base case: 0 times
($x:expr, 0) => {
$x
};
// Recursive case
($x:expr, $n:expr) => {
expand_n_times!($x + 1, $n - 1)
};
}
let result = expand_n_times!(0, 5); // Expands 5 times, then stops
For procedural macros, avoid recursively calling quote! on the output of quote!. Instead, build code directly without re-expansion.
Pitfall 4: Missing or Wrong Span Information
Macros that use Span::call_site() indiscriminately hide the actual error location. When a user gets a compile error, they should be able to point to the exact line in their code that caused it.
Example: Hidden Span
let expanded = quote! {
impl MyTrait for #name {
fn required_method(&self) {
panic!("Not implemented"); // Span is at the macro invocation, confusing
}
}
};
The user sees:
error[E0506]: cannot borrow `x` as mutable because it is also borrowed as immutable
--> src/main.rs:10:5
|
10 | #[derive(MyTrait)]
| ^^^^^^^^^^^^^^^^^
The error is at the derive, not the actual problem in the function body.
Solution: Use Original Spans
When possible, preserve the original token's span:
let expanded = quote_spanned! { name.span() =>
impl MyTrait for #name {
fn required_method(&self) {
panic!("Not implemented");
}
}
};
This attaches the error to the struct definition, not the macro invocation.
Pitfall 5: Type Mismatches from Overly Generic Code
A macro that tries to be "too general" often generates code that works for some types but fails for others, leading to confusing errors.
Example: Generic Serialization
// Bad: Assumes all fields are serializable to JSON
#[proc_macro_derive(ToJson)]
pub fn derive_to_json(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let serialize_fields = match &input.data {
Data::Struct(data) => {
match &data.fields {
Fields::Named(fields) => {
let names: Vec<_> = fields.named.iter()
.map(|f| f.ident.as_ref().unwrap())
.collect();
quote! {
#(
map.insert(stringify!(#names), serde_json::to_value(&self.#names).unwrap());
)*
}
}
_ => quote! {},
}
}
_ => quote! {},
};
// If a field is not serializable, this fails at runtime unwrap() or compile time
let expanded = quote! {
impl #name {
fn to_json(&self) -> serde_json::Map<String, serde_json::Value> {
let mut map = serde_json::Map::new();
#serialize_fields
map
}
}
};
TokenStream::from(expanded)
}
Solution: Add Bounds or Validate at Macro Time
Generate bounds in trait implementations:
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
// Add bounds to ensure all types support serialization
let mut bounds = input.generics.clone();
for param in bounds.params.iter_mut() {
if let syn::GenericParam::Type(type_param) = param {
type_param.bounds.push(syn::parse_quote!(serde::Serialize));
}
}
let expanded = quote! {
impl #impl_generics #name #ty_generics #where_clause {
// implementation that now has the Serialize bound
}
};
Pitfall 6: Performance: Compile Time Bloat
Procedural macros run at compile time and can slow builds significantly if they do expensive computation or generate massive amounts of code.
Identifying Slow Macros
Use cargo build --timings:
cargo build --timings
This generates a timeline of compile phases. If a macro crate shows high compile time, investigate.
Optimization Strategies
- Cache Results: If a macro does expensive computation, cache results when possible.
- Generate Only Needed Code: Avoid generating dead code paths for rare use cases.
- Lazy Evaluation: If a macro can defer computation to runtime, consider doing so.
Example: Optimized Serialization
// Bad: Generates full implementations for all fields
#[proc_macro_derive(Serialize)]
pub fn derive_serialize_bad(input: TokenStream) -> TokenStream {
// Expensive: iterates fields multiple times
// ...
}
// Good: Reuse computed field info
#[proc_macro_derive(Serialize)]
pub fn derive_serialize_good(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
// Compute field info once
let fields: Vec<_> = match &input.data {
Data::Struct(data) => match &data.fields {
Fields::Named(f) => f.named.iter().collect(),
_ => vec![],
},
_ => vec![],
};
// Reuse computed fields for all code generation
// instead of re-iterating
let serializers = fields.iter().map(|f| {
let name = f.ident.as_ref().unwrap();
quote! { self.#name.serialize()?; }
});
// Single, efficient quote!
let expanded = quote! {
impl Serialize for #name {
fn serialize(&self) -> Result<()> {
#( #serializers )*
Ok(())
}
}
};
TokenStream::from(expanded)
}
Pitfall 7: Hygiene Violations
While Rust macros are hygenic by default, you can accidentally break hygiene by using identifiers that conflict with user code.
Example: Hygiene Violation
macro_rules! counter {
() => {
let __counter = 0; // Poor naming: could collide with user's __counter
let __temp = __counter + 1;
__temp
};
}
let __counter = 100;
let result = counter!(); // Works, but relies on hygiene; bad practice
Solution: Use Unique Names and Trust Hygiene
macro_rules! counter {
() => {
{
let counter = 0; // Simple, readable; hygiene prevents collision
counter + 1
}
};
}
Key Takeaways
- Use
cargo expandto debug macro expansions and understand what code is generated - Emit errors with
syn::Error::new_spanned()and meaningful messages, not cryptic compiler errors - Use
quote_spanned!to attach errors to original tokens, improving diagnostics - Avoid recursive or unbounded macros that can trigger infinite expansion
- Add type bounds to generated trait implementations to catch type errors early
- Monitor compile time with
cargo build --timingsand optimize expensive macros - Trust macro hygiene; avoid creating obscurely-named variables that might collide
Frequently Asked Questions
My macro works locally but fails in CI. What should I check?
Different Rust versions may have different proc-macro behavior. Check that your macro crate's MSRV (minimum supported Rust version) matches your CI environment. Also verify that dependencies are at pinned versions.
How do I test a procedural macro?
Use trybuild to test compilation success/failure. Write test cases as Rust files in tests/ and assert whether they compile or emit specific errors. Example:
#[test]
fn it_compiles() {
let t = trybuild::TestCases::new();
t.pass("tests/pass/*.rs");
t.compile_fail("tests/fail/*.rs");
}
What is the difference between quote! and quote_spanned!?
quote! uses Span::call_site() by default; errors are attached to the macro invocation. quote_spanned! accepts an explicit span, usually from an original token, so errors point to the actual problem location.
Can I use external crates in a procedural macro?
Yes. Add them to Cargo.toml under [dependencies]. However, be aware that the macro crate is compiled and executed during the host compilation, so dependencies add to build time.
How do I measure the impact of my macro on compile time?
Use cargo build --timings and compare the crate compilation times. For detailed profiling, use cargo build -Z timings with unstable Rust features, or use external profilers.