Common Macro Patterns and Best Practices in Rust

Use declarative macros for repetition and procedural macros for custom derives to generate code at compile time.

When copy-paste becomes a liability

You are building a configuration parser. You have five structs: DatabaseConfig, ServerConfig, CacheConfig, AuthConfig, and LoggingConfig. Every struct needs a validate() method. The logic is identical for all of them: check if required fields are empty, verify numeric ranges, ensure enums have valid variants. You write the method for DatabaseConfig. You copy-paste it four times. You tweak the field names. You find a bug in the range check logic. You fix it in three places. You miss one. The bug ships.

Macros stop this cycle. They let you write the validation logic once and generate the method for every struct. You change the logic in one place, and the generated code updates everywhere. Macros also enable domain-specific languages inside Rust. You can write route!(GET "/api/users" => handler); and have the compiler generate the routing boilerplate. Macros turn repetitive syntax into a single definition.

Macros are code generators

A macro is a function that runs during compilation instead of at runtime. A runtime function takes values, executes logic, and returns a value. A macro takes tokens, executes logic, and returns Rust source code. The compiler runs the macro, injects the generated code into your file, and then compiles the result. You never execute the macro at runtime. The macro definition disappears after compilation.

Think of a macro as a template engine. You define a pattern that describes valid input. You define an output template that describes the generated code. When you call the macro, the compiler matches your input against the pattern. If the match succeeds, the compiler fills in the blanks in the output template. If the match fails, the compiler rejects the code with an error pointing to the macro call.

The generated code is indistinguishable from code you wrote by hand. It undergoes the same type checking, borrow checking, and optimization. If the macro generates invalid code, you get standard compiler errors. The only difference is that the error points to the macro call site, not the macro definition.

/// Generates a println call with a custom tag.
macro_rules! tagged_log {
    // Match a tag string and an expression.
    ($tag:expr, $value:expr) => {
        // Generate a println that includes the tag.
        println!("[{}] {}", $tag, $value);
    };
}

fn main() {
    // The compiler replaces this with println!("[INFO] {}", 42);
    tagged_log!("INFO", 42);
}

The anatomy of macro_rules!

Declarative macros use the macro_rules! syntax. The definition consists of a name and a list of rules. Each rule has a pattern and a template. The compiler tries the rules in order. The first rule that matches wins. If no rule matches, the compiler emits an error.

Patterns use fragment specifiers to capture parts of the input. A fragment specifier tells the compiler what kind of Rust syntax to expect. Common specifiers include :expr for expressions, :ident for identifiers, :path for paths, and :tt for token trees. The captured fragments become variables you can reference in the template using $name.

The template is the output code. It can contain literal Rust syntax and references to captured fragments. When the compiler generates the output, it substitutes the fragments into the template. The result is valid Rust code that gets compiled.

Convention aside: Name your macros like functions, but remember the !. The definition uses macro_rules! foo, and the usage is foo!(). The community expects macro names to be lowercase with underscores, just like functions. The ! distinguishes the call from a function call.

/// Matches an identifier and generates a constant with a default value.
macro_rules! define_constant {
    // Match an identifier and an optional expression.
    // The $(...)? syntax makes the second part optional.
    ($name:ident $(= $default:expr)?) => {
        // Generate a const declaration.
        // If a default was provided, use it. Otherwise, use 0.
        const $name: i32 = $crate::unwrap_or_default!($default);
    };
}

// Helper to handle the optional default.
macro_rules! unwrap_or_default {
    ($val:expr) => { $val };
    () => { 0 };
}

fn main() {
    // Expands to const MAX_SIZE: i32 = 100;
    define_constant!(MAX_SIZE = 100);

    // Expands to const MIN_SIZE: i32 = 0;
    define_constant!(MIN_SIZE);
}

Treat fragment specifiers as contracts. They tell the compiler how to parse the input. If you use the wrong specifier, the compiler might accept invalid input or reject valid input. Choose the most specific specifier that fits your needs.

Fragment specifiers and matching

The choice of fragment specifier determines what the macro accepts. :expr matches any valid expression, including literals, variables, function calls, and complex operations. :ident matches a single identifier, like a variable name or type name. :path matches a path, which can be a simple name or a qualified path like std::vec::Vec. :tt matches a single token tree, which is either a single token or a balanced group of tokens.

Use :expr when you expect a value or computation. Use :ident when you expect a name. Use :path when you expect a type or module path. Use :tt only when you need to capture punctuation or complex groups that other specifiers reject. :tt is dangerous because it can swallow tokens you didn't intend. It matches the smallest valid token tree, which might not align with your expectations.

Convention aside: Prefer specific fragments over :tt. Specific fragments give better error messages and prevent accidental over-capture. If you use :tt, add a comment explaining why you need it. Future maintainers will thank you.

/// Generates a match arm for a specific identifier.
macro_rules! match_ident {
    // Match an identifier and generate a match arm.
    ($name:ident) => {
        // Generate an arm that prints the identifier.
        $name => println!("Matched {}", stringify!($name)),
    };
}

fn main() {
    let value = "hello";

    // The compiler generates the match arm inline.
    match value {
        match_ident!(hello)
        _ => println!("No match"),
    }
}

Trust the fragment specifiers. They enforce structure. If you find yourself reaching for :tt to fix a parsing error, reconsider your design. You might be trying to match syntax that doesn't fit the pattern.

Repetition and the comma trap

Macros can generate repeated code using repetition syntax. The $(...)* syntax repeats the content zero or more times. The $(...)+ syntax repeats one or more times. You can specify a separator like , or => to match lists. The repetition generates output for each match.

Repetition is essential for processing lists. You can match a list of expressions and generate a loop, a match block, or a series of statements. The repetition syntax mirrors the input structure. If you match $( $x:expr ),*, you capture a comma-separated list of expressions. The template can use $( $x ),* to repeat the expressions in the output.

Convention aside: Always support trailing commas. Users expect vec![1, 2, 3,] to work. Your macro should too. Add $(,)? to your repetition patterns to make the final comma optional. It costs nothing and saves headaches.

/// Generates a function that sums a list of expressions.
macro_rules! sum_exprs {
    // Match a list of expressions with optional trailing comma.
    ($($expr:expr),+ $(,)?) => {
        // Generate a block that adds all expressions.
        {
            let mut result = 0;
            $(
                result += $expr;
            )*
            result
        }
    };
}

fn main() {
    // Expands to a block that adds 1, 2, and 3.
    let total = sum_exprs!(1, 2, 3);
    println!("{}", total);
}

Test your repetition patterns with edge cases. Empty lists, single items, and trailing commas should all behave correctly. If your macro rejects a trailing comma, users will complain. If it accepts an empty list when it shouldn't, you'll get confusing errors.

Hygiene and scope safety

Macros are hygienic by default. This means identifiers introduced inside the macro don't clash with identifiers in the caller's scope. If you define a variable x inside a macro, it won't shadow an x in the calling function. The compiler treats macro-generated identifiers as if they belong to a separate namespace.

Hygiene prevents subtle bugs where a macro accidentally overwrites a local variable. It makes macros safe to compose. You can use a macro inside another macro without worrying about variable names. You can call a macro in a loop without worrying about capturing the loop variable.

Hygiene applies to identifiers, not to tokens. If your macro generates code that references a variable from the caller's scope, that reference is resolved in the caller's scope. This is intentional. It allows macros to use context from the call site.

/// Generates a debug print for a variable.
macro_rules! debug_var {
    // Match an identifier.
    ($var:ident) => {
        // Generate a println that references the variable.
        // The reference resolves in the caller's scope.
        println!("{} = {:?}", stringify!($var), $var);
    };
}

fn main() {
    let x = 42;
    let y = "hello";

    // The macro references x and y from this scope.
    debug_var!(x);
    debug_var!(y);
}

Trust hygiene. It exists to save you from yourself. If you need to break hygiene, you're probably doing it wrong. Use macro_rules! features carefully. Hygiene is a feature, not a bug.

Recursive macros

Macros can call themselves. This is how you process complex structures or implement algorithms. Recursive macros define a base case and a recursive case. The base case stops the recursion. The recursive case reduces the input and calls the macro again.

Recursion is powerful but dangerous. If you forget the base case, the macro recurses until it hits the compiler's recursion limit. You'll get a "recursion limit reached" error. Always ensure your recursive case reduces the input.

Convention aside: Use @ rules for internal recursion. Define a public rule that users call and internal rules that start with @. This keeps the public API simple. Users call sum!(1, 2, 3), not sum!(@recurse 1, 2, 3).

/// Sums a list of numbers using recursion.
macro_rules! sum {
    // Base case: single number.
    ($x:expr) => {
        $x
    };

    // Recursive case: head and tail.
    // The @ rule handles the recursion internally.
    ($head:expr, $($tail:expr),+) => {
        $head + sum!($($tail),+)
    };
}

fn main() {
    // Expands to 1 + (2 + (3 + 4))
    let total = sum!(1, 2, 3, 4);
    println!("{}", total);
}

Expand before you guess. cargo expand shows the truth. Your brain lies about what the macro generates. If recursion feels tricky, expand the macro and inspect the output.

Procedural macros: when declarative isn't enough

Declarative macros match syntax patterns. Procedural macros parse the Abstract Syntax Tree (AST) programmatically. Procedural macros are Rust functions that take tokens and return tokens. They run in a separate crate and use libraries like syn and quote to parse and generate code.

Procedural macros come in three flavors: #[derive] macros, attribute macros, and function-like macros. #[derive] macros generate trait implementations. Attribute macros modify existing items. Function-like macros look like function calls but generate code.

Use procedural macros when you need to inspect the AST. You can analyze struct fields, method signatures, or generic parameters. You can generate code based on semantic properties. Declarative macros can't do this. They only match syntax patterns.

Procedural macros are more powerful but harder to write. They require a separate crate. They use syn to parse tokens into Rust data structures. They use quote to generate tokens from Rust code. Error handling is manual. You need to return TokenStream errors.

Convention aside: Name procedural macro crates with a -proc-macro suffix. This signals to users that the crate contains procedural macros. Set proc-macro = true in Cargo.toml. This tells Cargo to compile the crate as a procedural macro library.

// This is a conceptual example of a derive macro structure.
// Actual implementation requires syn and quote crates.

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(Debug)]
pub fn derive_debug(input: TokenStream) -> TokenStream {
    // Parse the input into a DeriveInput.
    let input = syn::parse_macro_input!(input as syn::DeriveInput);

    // Generate the Debug implementation.
    let expanded = quote! {
        impl std::fmt::Debug for #input {
            fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
                write!(f, "Debug implementation")
            }
        }
    };

    // Return the generated code.
    TokenStream::from(expanded)
}

Reach for declarative macros first. They are simpler, faster, and easier to debug. Use procedural macros only when declarative macros cannot solve the problem.

Pitfalls and debugging

Macros generate code. If the generated code is invalid, you get compiler errors. The errors point to the macro call site, not the macro definition. This can be confusing. You might see an error about a missing trait or a type mismatch and wonder where it came from.

If your macro generates code that uses a type without the right trait, the compiler rejects it with E0277 (trait bound not satisfied). The error points to the macro call. You need to ensure the generated code includes the necessary trait bounds or derives. If your macro generates code with mismatched types, you get E0308 (mismatched types). Check the generated code to find the source.

Debugging macros requires expansion. Use cargo expand to see the generated code. Install the cargo-expand crate. Run cargo expand to see the full expansion of your crate. You can also expand a specific file or macro. This shows you exactly what the compiler sees.

Macros can also cause performance issues. Large macros generate large amounts of code. This increases compile time. Keep macros small and focused. Avoid generating unnecessary code. If a macro generates hundreds of lines, consider breaking it up.

Convention aside: Document your macros. Use /// doc comments. Explain the pattern and the output. Show examples. Users need to know how to call your macro. Good documentation reduces support burden.

Treat the macro output as production code. If you wouldn't write it by hand, don't generate it with a macro. Macros should simplify code, not obscure it.

Decision matrix

Use macro_rules! when you need to match patterns and generate code based on syntax structure. Use macro_rules! for DSLs, repetitive match arms, or generating boilerplate like match statements for enums.

Use procedural macros when you need to inspect the Abstract Syntax Tree programmatically. Use procedural macros for #[derive] implementations where you parse struct fields and generate trait methods.

Use functions when the repetition is data-driven rather than syntax-driven. Use functions if you can pass a closure or a trait object to handle variation.

Use const functions when you need compile-time evaluation without code generation. Use const functions for calculations that happen during compilation but don't require generating new syntax.

Use traits when you need polymorphism. Use traits if you can define a common interface and implement it for multiple types. Traits are safer and more flexible than macros for most use cases.

If you can write a function, write a function. Macros add complexity. Only use them when functions cannot solve the problem.

Where to go next