What Is the Difference Between Declarative and Procedural Macros?

Declarative macros use `macro_rules!` to match patterns and expand code, while procedural macros are Rust functions that programmatically generate code for custom derives, attributes, or function-like calls. Declarative macros are defined with `macro_rules!` and work like pattern matching, whereas p

When templates aren't enough

You are building a configuration system for a game engine. You start with a macro config!() that parses a simple key-value DSL. You use macro_rules! and it works fine. Then you need nested objects. You add recursive patterns. Then you need to validate that keys match enum variants. The patterns become a maze of wildcards. The error messages point to the macro definition instead of the user's code. You realize macro_rules! is a template engine, not a parser. This is the moment you choose between declarative and procedural macros.

Rust offers two ways to generate code at compile time. Declarative macros use macro_rules! to match patterns and expand code. Procedural macros are Rust functions that receive source code as tokens, run logic, and return new code. One describes the transformation. The other implements the transformation.

Declarative macros: pattern matching with wildcards

Declarative macros are defined with macro_rules!. You write a set of rules. Each rule has a pattern and a template. When the compiler sees a macro call, it tries to match the input against your patterns. If a pattern matches, the compiler substitutes the matched fragments into the template and compiles the result.

Think of macro_rules! as a smart find-and-replace. You define wildcards like $name:ident to capture an identifier. You define $expr:expr to capture an expression. The macro expands by filling in the blanks. You do not write loops, conditionals, or parsing logic. You only describe what the input looks like and what the output should be.

/// Logs a variable's name and value at compile time.
macro_rules! log_var {
    // Pattern: capture a single identifier.
    ($var:ident) => {
        // Template: stringify the name and print the value.
        println!("Variable {} has value: {:?}", stringify!($var), $var);
    };
}

fn main() {
    let x = 42;
    // Expands to: println!("Variable x has value: {:?}", stringify!(x), x);
    log_var!(x);
}

Declarative macros live in the same crate as the code that uses them. You do not need a separate crate. You do not need extra dependencies. The compiler handles them entirely during the macro expansion phase.

Procedural macros: functions that write code

Procedural macros are actual Rust functions. They run during compilation. The compiler calls your function, passes the source code as a stream of tokens, and expects you to return a new stream of tokens. You can use loops, conditionals, data structures, and any logic you need to generate the output.

Procedural macros require a separate crate. You must set proc-macro = true in the crate's Cargo.toml. The crate can only export procedural macros; it cannot contain normal library code. This separation exists because procedural macros run in a special context with access to the compiler's internal token representation.

// In a separate crate `my_macros` with proc-macro = true in Cargo.toml
use proc_macro::TokenStream;

/// A procedural macro that returns a fixed println statement.
#[proc_macro]
pub fn hello_world(_input: TokenStream) -> TokenStream {
    // Procedural macros return a TokenStream.
    // This example ignores input and returns fixed code.
    // In practice, you would parse the input and generate dynamic output.
    "println!(\"Hello from a proc macro!\");".parse().unwrap()
}

Procedural macros come in three flavors. Custom derives attach to structs or enums with #[derive(MyMacro)]. Custom attributes attach to items with #[my_attr]. Function-like macros look like normal macro calls with my_macro!(args). All three use the same underlying mechanism: a function that transforms tokens.

How expansion works

When the compiler encounters a macro call, it pauses compilation of the current file. It expands the macro into raw code. It then continues compilation with the expanded code. This happens recursively. A macro can call another macro. The compiler expands the inner macro first, then the outer macro.

For declarative macros, the compiler matches the input against your patterns. If no pattern matches, the compiler emits an error. If multiple patterns could match, the compiler picks the first one. Pattern order matters.

/// A macro that handles different argument counts.
macro_rules! greet {
    // First pattern: no arguments.
    () => {
        println!("Hello!");
    };
    // Second pattern: one identifier.
    ($name:ident) => {
        println!("Hello, {}!", $name);
    };
    // Third pattern: one identifier and an expression.
    ($name:ident, $msg:expr) => {
        println!("Hello, {}! {}", $name, $msg);
    };
}

fn main() {
    greet!();           // Matches first pattern.
    greet!(Alice);      // Matches second pattern.
    greet!(Bob, "howdy"); // Matches third pattern.
}

For procedural macros, the compiler calls your function. Your function receives the tokens. You parse them, manipulate them, and return the result. If your function panics, the compiler reports a panic error. If you return invalid tokens, the compiler reports a syntax error on the generated code.

Realistic examples

Declarative macros shine for simple code generation. They are perfect for reducing boilerplate when the pattern is fixed. A common use case is a macro that wraps a function call with logging or error handling.

/// Wraps a function call and logs the result.
macro_rules! call_and_log {
    // Capture the function call as an expression.
    ($fn_call:expr) => {
        {
            // Create a temporary variable to hold the result.
            let result = $fn_call;
            // Log the result using debug formatting.
            println!("Result: {:?}", result);
            // Return the result so the macro can be used in expressions.
            result
        }
    };
}

fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn main() {
    // Expands to a block that calls add, logs, and returns.
    let sum = call_and_log!(add(2, 3));
}

Procedural macros shine when you need to inspect the structure of code. They are the only way to write custom derives. If you want to generate serialization code for a struct, you need to iterate over the struct's fields. Declarative macros cannot do this. Procedural macros can.

// In a procedural macro crate using syn and quote.
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};

/// A custom derive that implements a simple Debug trait.
#[proc_macro_derive(SimpleDebug)]
pub fn simple_debug_derive(input: TokenStream) -> TokenStream {
    // Parse the input into an AST.
    let input = parse_macro_input!(input as DeriveInput);
    
    // Extract the struct name.
    let name = &input.ident;
    
    // Generate the trait implementation.
    let expanded = quote! {
        impl std::fmt::Debug for #name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                write!(f, "{} {{ ... }}", #name)
            }
        }
    };
    
    // Return the generated code.
    TokenStream::from(expanded)
}

The community convention for procedural macros is to use the syn crate for parsing and the quote crate for generating code. Parsing tokens manually is error-prone and verbose. syn gives you a structured AST. quote lets you write code that looks like Rust but generates tokens. This stack is the standard. Deviate from it only if you have a compelling reason.

Pitfalls and compiler errors

Declarative macros have limits. They cannot perform arbitrary computation. They cannot loop over a variable number of items. They rely on recursion to simulate loops, which can hit recursion limits. If you write a recursive macro that expands too deeply, the compiler rejects it with a recursion limit error.

Pattern ambiguity is another trap. If two patterns could match the same input, the compiler picks the first one. This can lead to subtle bugs if you expect a different pattern to match. The compiler warns about unreachable patterns. Pay attention to these warnings.

/// A macro with an unreachable pattern.
macro_rules! bad_macro {
    // This pattern matches any identifier.
    ($x:ident) => {
        println!("{}", $x);
    };
    // This pattern is unreachable because the first pattern matches everything.
    // The compiler warns: unreachable pattern.
    ($y:ident) => {
        println!("{}", $y);
    };
}

Procedural macros have their own challenges. Error reporting is harder. If your macro encounters invalid input, you must return an error token stream. If you panic, the compiler shows a panic message instead of a helpful diagnostic. Use the proc_macro_error crate to simplify error reporting. It provides macros to emit errors with spans.

Hygiene is another concern. macro_rules! is hygienic by default. Identifiers introduced by the macro do not capture variables from the caller. This prevents bugs where a macro accidentally shadows a local variable. Procedural macros let you control hygiene with spans. Use Span::call_site() to inherit the caller's context. Use Span::mixed_site() for mixed behavior. The convention is mixed_site() for most generated code so errors point to the right place.

If you forget to handle hygiene correctly, you may get E0425 (cannot find value) or E0412 (cannot find type) errors that point to the macro call but stem from identifier capture. Treat spans carefully. When in doubt, use quote's default span handling, which usually does the right thing.

Hygiene: the silent guardian

Hygiene is the mechanism that prevents macros from breaking the surrounding code. A macro that introduces a variable i should not shadow the caller's i. macro_rules! handles this automatically. The compiler treats identifiers inside the macro as distinct from identifiers in the caller's scope.

Procedural macros give you control. You can choose whether generated code captures the caller's variables or introduces new ones. This flexibility is powerful but dangerous. If you use Span::call_site(), the generated code behaves as if it were written at the call site. Variables from the caller are visible. If you use Span::def_site(), the generated code behaves as if it were written at the definition site. Caller variables are not visible.

The convention is to use Span::mixed_site() for most procedural macros. This allows the macro to introduce new identifiers without capturing caller variables, while still allowing the caller to pass in identifiers that should be captured. The quote crate uses mixed_site() by default. Trust this default unless you have a specific reason to change it.

When to use which

Use macro_rules! when the transformation follows clear patterns and you can describe the output by filling in wildcards. Use macro_rules! when you want the macro definition to live in the same crate as the code that calls it. Use macro_rules! when you need automatic hygiene: the compiler handles variable capture so your macro does not accidentally clash with local names. Use macro_rules! for simple boilerplate reduction, like wrapping function calls or generating repetitive struct definitions.

Use procedural macros when the code generation requires logic, loops, or conditionals that pattern matching cannot express. Use procedural macros when you are building a custom derive, a custom attribute, or a function-like macro that takes arbitrary arguments. Use procedural macros when you need to inspect the structure of a type deeply, like iterating over struct fields to generate serialization code. Use procedural macros when you need to generate code based on external data, like reading a configuration file during compilation.

Start with macro_rules!. If the patterns start looking like a maze, switch to procedural. The compiler will guide you. If you hit recursion limits or pattern ambiguity, it is time to move on.

Where to go next