How to write declarative macro

Define a declarative macro in Rust using the macro_rules! keyword to create reusable code patterns that expand at compile time.

When functions hit a wall

You are building a configuration loader. You want a clean API where the user writes config!("DATABASE_URL", "postgres://localhost"). If the environment variable exists, use it. If not, fall back to the default. You try writing a function. You hit a wall. A function evaluates its arguments before it runs. You cannot write a function that evaluates the default value only when the variable is missing, without wrapping the default in a closure and losing the clean syntax. You also want the macro to capture the file and line number for error reporting, something functions cannot do without passing extra parameters.

This is where declarative macros shine. Macros do not take values. They take syntax. They run before the compiler checks types. They let you define patterns of code and generate new code based on those patterns. When you call a macro, the compiler expands it into regular Rust code, then compiles the result.

Macros are code that writes code

Think of a macro as a template engine that runs inside the compiler. You feed it tokens, it matches them against rules, and it spits out Rust code. The output looks like you typed it by hand.

The macro_rules! construct is the workhorse of Rust macros. It defines a set of arms. Each arm has a pattern and a template. When you invoke the macro, the compiler tries to match the input against the patterns in order. The first match wins. The compiler replaces the macro call with the template, substituting the matched parts.

Macros are hygienic. This means identifiers introduced inside a macro do not clash with identifiers in the calling scope. If your macro defines a variable named temp, it won't accidentally shadow a temp variable in the function that calls the macro. The compiler handles the renaming behind the scenes.

The anatomy of macro_rules!

Here is the minimal structure. You define the macro with macro_rules!, followed by the name and a body containing arms.

/// Doubles a numeric expression by adding it to itself.
macro_rules! double {
    // Match a single expression and bind it to the name $x.
    ($x:expr) => {
        // Generate code that adds the expression to itself.
        // This expansion happens at compile time.
        $x + $x
    };
}

fn main() {
    // The macro expands to `5 + 5` before main runs.
    let val = double!(5);
    println!("{}", val);
}

The pattern ($x:expr) tells the compiler to expect an expression and bind it to $x. The template $x + $x uses that binding. When you call double!(5), the compiler matches 5 against $x:expr. It succeeds. It replaces the call with 5 + 5. The result compiles and runs.

Convention: Macro names follow the same casing as functions. Use lowercase with underscores. The ! is part of the call syntax, not the name. You define my_macro, but you call my_macro!().

Fragment specifiers: telling the compiler what to expect

The :expr part of $x:expr is a fragment specifier. It tells the compiler what kind of syntax to match. Using the right specifier gives you better error messages and prevents the macro from accepting nonsense.

Common specifiers include:

  • expr: Matches any expression. 42, x + y, foo().
  • ident: Matches a single identifier. x, my_var, Foo.
  • tt: Matches a token tree. This is the escape hatch. It matches anything that isn't whitespace, including parentheses, brackets, braces, and punctuation.
  • pat: Matches a pattern. Used in match arms or let bindings.
  • ty: Matches a type. i32, Vec<String>, Option<T>.

If you use tt when you should use expr, the compiler cannot check if the input makes sense as an expression. You get errors deep in the expansion rather than at the call site.

Convention: Prefer specific specifiers like expr or ident over tt. tt matches too much, which hides mistakes until the generated code fails to compile. Use tt only when you need to accept arbitrary syntax that doesn't fit other categories.

Repetition: handling lists and sequences

Macros become powerful when they handle repetition. You can match zero or more items, one or more items, or a specific count. The syntax uses $() with a repetition operator.

The operators are * for zero or more, + for one or more, and {m, n} for a range. You can also add separators like , or ; between repetitions.

/// Creates a tuple from a list of expressions.
macro_rules! make_tuple {
    // Match zero or more expressions separated by commas.
    // The $(...),* syntax captures the sequence.
    ($($x:expr),*) => {
        // Generate a tuple containing all matched expressions.
        // The repetition in the template mirrors the pattern.
        ($($x),*)
    };
}

fn main() {
    // Expands to (1, 2, 3).
    let t = make_tuple!(1, 2, 3);
    println!("{:?}", t);
}

The pattern ($($x:expr),*) matches a comma-separated list of expressions. The $() groups the repetition. Inside the template, ($($x),*) expands the list back into a tuple. If you call make_tuple!(), it matches zero items and expands to ().

Repetition syntax can get tricky. The compiler requires the repetition structure in the template to match the pattern. You cannot repeat a fragment in the template if it wasn't repeated in the pattern. If you need to repeat something that wasn't captured, you must use a separator or restructure the macro.

A realistic example: conditional logging

Here is a macro that logs messages with file and line numbers, but only in debug builds. This avoids the runtime cost of formatting strings in release builds.

/// Logs a debug message with file and line info.
/// Compiles away to nothing in release builds.
macro_rules! debug_log {
    // Match a format string and optional arguments.
    // The $(, $arg:expr)* handles zero or more arguments.
    ($msg:expr $(, $arg:expr)*) => {
        // Check the cfg flag at compile time.
        // If debug_assertions are disabled, this branch is empty.
        if cfg!(debug_assertions) {
            // Expand to eprintln with file and line metadata.
            // The repetition passes arguments to format!.
            eprintln!("[{}:{}] {}", file!(), line!(), format!($msg $(, $arg)*));
        }
    };
}

fn main() {
    let user = "Alice";
    // In debug mode, expands to an if-check with eprintln.
    // In release mode, expands to nothing.
    debug_log!("User {} logged in", user);
}

The macro uses cfg!(debug_assertions) to gate the code. The compiler evaluates this constant at compile time. If the condition is false, the entire if block disappears. The generated code has zero overhead in release builds.

The repetition $(, $arg:expr)* allows calling debug_log!("msg") or debug_log!("msg {}", arg). The template mirrors this with format!($msg $(, $arg)*).

Pitfalls and compiler errors

Macros expand before type checking. This creates unique failure modes.

If you call a macro with arguments that do not match any arm, the compiler rejects the code. You see an error like "no rules expected the token ,". This happens when the input syntax does not fit the pattern. The compiler lists the expected patterns to help you debug.

Side effects are a common trap. If your macro evaluates an expression multiple times, side effects happen multiple times.

macro_rules! double {
    ($x:expr) => { $x + $x };
}

fn main() {
    let mut counter = 0;
    // Expands to `counter += 1 + counter += 1`.
    // This increments counter twice.
    let _ = double!(counter += 1);
    println!("{}", counter); // Prints 2, not 1.
}

The macro expands to counter += 1 + counter += 1. The increment runs twice. This is correct behavior for the macro, but often not what the caller expects. Document macros that evaluate arguments multiple times. Prefer macros that capture values in let bindings if side effects are a concern.

Hygiene can surprise you. Macros introduce new scopes. If you use return inside a macro, it returns from the function that called the macro, not from the macro itself. This is usually desired, but it can break control flow if you are not careful.

Convention: Keep macros small. A macro that generates hundreds of lines of code is hard to debug. Break complex macros into smaller helper macros. The community calls this the "minimum macro surface" rule.

Test your macros with edge cases. Call them with empty arguments, with complex expressions, and with side effects. A macro that expands to invalid code produces confusing error messages that point to the expansion, not the definition.

Decision: macros versus functions

Use a function when you need type checking and the input is data, not syntax. Functions are faster to compile, easier to debug, and integrate with the type system.

Use a declarative macro when you need to match patterns in code and generate different expansions based on those patterns. Functions cannot inspect syntax.

Use a declarative macro when you need to capture file, line, or column information that functions cannot access. Macros have access to metadata about the call site.

Use a declarative macro when you want to conditionally compile code based on the presence of tokens. Macros can generate code that depends on cfg flags or trait implementations that are not known at the function level.

Reach for a function first. Macros add complexity and obscure the call stack. Only write a macro when a function literally cannot solve the problem.

Trust the compiler. If you can express your logic with functions and traits, do it. Macros are the power tools. Keep them in the box until you need to cut through steel.

Where to go next