Rust Macros 101 - Declarative Macros with macro_rules!

April 03, 2026 ยท 11 min read
rustmacrosmetaprogramming

Before you reach for proc macros, syn, and quote - there's an entire code generation system built into the language that requires zero dependencies and compiles in milliseconds. Declarative macros with macro_rules! are Rust's pattern-matching code generator. They're not as flexible as proc macros, but they cover a surprising amount of ground, and understanding them is a prerequisite for understanding the rest of the macro ecosystem.

This post covers how macro_rules! actually works - the matching rules, repetitions, hygiene model, and the sharp edges you'll hit. We'll build three practical macros along the way.

What macro_rules! actually does

A macro_rules! definition is a set of pattern-matching arms. When you invoke the macro, the compiler tries each arm top-to-bottom until one matches. The matched arm's right-hand side becomes the expanded code.

macro_rules! greet {
    () => {
        println!("hello, world");
    };
    ($name:expr) => {
        println!("hello, {}", $name);
    };
}

greet!();           // expands to: println!("hello, world");
greet!("Alice");    // expands to: println!("hello, {}", "Alice");

Each arm has the form (matcher) => { transcriber }. The matcher is a pattern against the token stream. The transcriber is the code that gets pasted in. Variables captured in the matcher (like $name) are substituted in the transcriber.

The semicolons between arms are required. The braces around the transcriber can be {}, (), or [] - doesn't matter. Convention is {}.

One thing that trips people up: this is not textual substitution like C preprocessor macros. The Rust compiler parses the input into a token stream, matches it against the pattern, and produces a new token stream. The output is then parsed as Rust code. This distinction matters for hygiene (more on that later).

Fragment specifiers - what your patterns can capture

The $name:spec syntax in matchers captures a piece of the input. The fragment specifier (spec) tells the compiler what kind of syntax construct to expect. Rust has 14 of them:

SpecifierMatchesExample
exprAny expression1 + 2, foo(), if x { 1 } else { 2 }
tyA typei32, Vec<String>, &'a str
identAn identifierfoo, MyStruct, x
patA patternSome(x), 1..=5, ref mut y
pathA pathstd::collections::HashMap, crate::foo
stmtA statementlet x = 1, x.push(2)
blockA block expression{ let x = 1; x + 2 }
itemAn item definitionfn foo() {}, struct Bar;, impl Baz {}
literalA literal42, "hello", true, 3.14
lifetimeA lifetime'a, 'static
metaAttribute contentsderive(Debug), cfg(test)
ttA single token treeAnything: a token or a delimited group
visA visibility qualifierpub, pub(crate), or empty
pat_paramA pattern (no top-level |)Like pat but allows | to follow

The most important ones you'll use day-to-day are expr, ty, ident, tt, and literal.

The 2024 edition change

If you're on the 2024 edition, expr now matches const { ... } blocks and _ expressions in addition to regular expressions. If you need the old behavior (because another arm starts with const), use expr_2021. Running cargo fix --edition will convert your macros automatically.

Fragment opacity

This is a subtle but important rule. When you capture something with any specifier other than ident, lifetime, or tt, the captured fragment becomes opaque. You can't re-match it with another specifier in a subsequent macro invocation.

macro_rules! capture {
    ($e:expr) => {
        inspect!($e);
    };
}

macro_rules! inspect {
    (1 + 2) => { println!("got literal addition"); };
    ($e:expr) => { println!("got something else"); };
}

capture!(1 + 2);
// Prints "got something else" - NOT "got literal addition"
// Because $e was captured as an opaque expr, it can't be
// destructured again by inspect!

If you need to pass tokens through multiple macro layers and re-match them, use $($t:tt)* - token trees preserve the full structure.

Follow-set restrictions

You can't put arbitrary tokens after a fragment specifier in a matcher. The compiler enforces follow-set restrictions to prevent ambiguity:

This means you can't write something like:

macro_rules! bad {
    ($e:expr $t:ty) => { };  // ERROR: ty cannot follow expr
}

You need a separator:

macro_rules! good {
    ($e:expr, $t:ty) => { };  // comma separates them - fine
}

These restrictions exist because the parser needs to unambiguously decide where one fragment ends and the next begins. If expr could be followed by anything, the compiler couldn't tell when the expression is done.

Repetitions

Repetitions are what make macro_rules! actually useful for code generation. The syntax is $( ... ) sep rep where:

macro_rules! make_vec {
    ( $( $elem:expr ),* ) => {
        {
            let mut v = Vec::new();
            $( v.push($elem); )*
            v
        }
    };
}

let v = make_vec![1, 2, 3];
// Expands to:
// {
//     let mut v = Vec::new();
//     v.push(1);
//     v.push(2);
//     v.push(3);
//     v
// }

In the transcriber, $( ... )* repeats its body once for each captured element. Every metavariable used inside the repetition must have been captured at the same repetition depth.

Nested repetitions

You can nest them:

macro_rules! matrix {
    ( $( [ $( $elem:expr ),* ] ),* ) => {
        vec![ $( vec![ $( $elem ),* ] ),* ]
    };
}

let m = matrix![[1, 2], [3, 4], [5, 6]];
// m: Vec<Vec<i32>> = [[1, 2], [3, 4], [5, 6]]

Trailing comma handling

A common pattern for accepting optional trailing commas:

macro_rules! my_macro {
    ( $( $item:expr ),* $(,)? ) => {
        // ...
    };
}

my_macro![1, 2, 3];   // works
my_macro![1, 2, 3,];  // also works

The $(,)? matches zero or one trailing comma. Get in the habit of adding this to every macro that takes comma-separated arguments - your users will thank you.

Practical macro #1: vec_of_strings!

The standard vec! macro creates a Vec<T>, but you often want Vec<String> from string literals. Writing .to_string() or .into() on every element gets old fast. Let's fix that:

macro_rules! vec_of_strings {
    ( $( $s:expr ),* $(,)? ) => {
        vec![ $( String::from($s) ),* ]
    };
}

let names = vec_of_strings!["Alice", "Bob", "Charlie"];
// Type: Vec<String>

// Also works with non-literal expressions:
let dynamic = "Dave";
let names = vec_of_strings!["Alice", dynamic, &format!("Mr. {}", "E")];

Short, useful, zero-cost at runtime (same code you'd write by hand). This is the sweet spot for declarative macros - eliminating repetitive boilerplate that's too small to justify a function.

Practical macro #2: assert_matches!

Standard assert_eq! checks for equality. But sometimes you want to check that a value matches a pattern. The standard library gained assert_matches! in nightly, but you can write your own:

macro_rules! assert_matches {
    ($value:expr, $pattern:pat $(,)?) => {
        match $value {
            $pattern => {}
            ref actual => panic!(
                "assertion failed: `{:?}` does not match `{}`",
                actual,
                stringify!($pattern)
            ),
        }
    };
    ($value:expr, $pattern:pat, $($msg:tt)+) => {
        match $value {
            $pattern => {}
            ref actual => panic!(
                "assertion failed: `{:?}` does not match `{}`: {}",
                actual,
                stringify!($pattern),
                format_args!($($msg)+)
            ),
        }
    };
}

#[derive(Debug)]
enum Response {
    Ok(u16),
    Error(String),
}

let resp = Response::Ok(200);
assert_matches!(resp, Response::Ok(200));
assert_matches!(resp, Response::Ok(_), "expected Ok variant");
// assert_matches!(resp, Response::Error(_)); // panics with useful message

A few things to notice:

The TT muncher pattern

When your macro needs to process a complex, recursive grammar, the TT muncher is the go-to pattern. It works by consuming tokens from the front of the input one piece at a time, generating output, and recursing on the tail.

The name comes from capturing the unprocessed input as $($tail:tt)* - you're "munching" token trees.

macro_rules! html {
    // Base case: nothing left to process
    () => { String::new() };

    // Self-closing tag: <br/>, then process remaining siblings
    (<$tag:ident /> $($rest:tt)*) => {{
        let mut out = format!("<{} />", stringify!($tag));
        out.push_str(&html!($($rest)*));
        out
    }};

    // Tag with text content: <p>"hello"</p>, then process remaining siblings
    (<$tag:ident> $content:literal </$close:ident> $($rest:tt)*) => {{
        let mut out = format!("<{}>{}</{}>",
            stringify!($tag), $content, stringify!($close));
        out.push_str(&html!($($rest)*));
        out
    }};
}

fn main() {
    let br = html!(<br />);
    assert_eq!(br, "<br />");

    let para = html!(<p> "hello world" </p>);
    assert_eq!(para, "<p>hello world</p>");

    // The real power: processing sibling elements
    let page = html!(
        <h1> "Title" </h1>
        <p> "First paragraph." </p>
        <br />
        <p> "Second paragraph." </p>
    );
    assert_eq!(
        page,
        "<h1>Title</h1><p>First paragraph.</p><br /><p>Second paragraph.</p>"
    );
}

This is a textbook TT muncher. Each arm matches a specific structure at the front of the token stream (a self-closing tag or a tag with content), processes it, then recurses on $($rest:tt)* - whatever's left. The base case () => { String::new() } terminates the recursion when all tokens are consumed.

Notice we can't handle deeply nested tags like <div><p>"hi"</p></div> - the $($rest:tt)* at the end is greedy and would swallow the closing </div>. This is genuinely where macro_rules! hits a wall. A proc macro with a proper token stream iterator handles nesting trivially. For a declarative macro, you'd need to use Rust's own delimiters (braces or brackets) to group children, which defeats the purpose of HTML-like syntax.

Performance warning

TT munchers are inherently quadratic in compile time. Processing N token trees requires the compiler to attempt matching against N tokens, then N-1, then N-2, and so on. For small inputs this is fine. For large inputs (hundreds of tokens), compile times balloon. If you find yourself building a parser with many TT muncher arms, that's the signal to switch to a proc macro, where you get a proper TokenStream iterator and linear-time processing.

Hygiene

C macros are infamous for variable name collisions. Rust's macro_rules! have a partial solution called mixed-site hygiene.

The rule is:

Here's what that means in practice:

let x = 1;

macro_rules! check {
    () => {
        // x resolves at definition site - it sees x = 1
        assert_eq!(x, 1);
    };
}

{
    let x = 99;
    check!();  // passes! x is 1, not 99
}

The x inside the macro refers to the x that was in scope when the macro was defined, not the x at the call site. This prevents accidental variable capture.

But hygiene has limits. You cannot define a variable in one macro invocation and use it in another:

macro_rules! m {
    (define) => { let x = 42; };
    (refer) => { dbg!(x); };
}

fn main() {
    m!(define);
    m!(refer);  // ERROR: cannot find value `x` in this scope
}

Each macro expansion gets its own syntax context. The x from m!(define) lives in a different context than the x that m!(refer) tries to access. They're different "colors" of x as far as the compiler is concerned.

This is usually what you want. But when you need a macro to introduce a binding that the caller can use, the trick is to let the caller name it:

macro_rules! let_val {
    ($name:ident = $val:expr) => {
        let $name = $val;
    };
}

let_val!(answer = 42);
println!("{answer}");  // works - the caller chose the name

Since $name is captured from the call site, it lives in the call site's syntax context. No collision.

Debugging with cargo expand

When your macro doesn't expand the way you expect, if you're not familiar with cargo-expand, I covered it in Debugging Rust Beyond println! - it's your best friend for macro development.

The short version:

cargo install cargo-expand
cargo expand           # expand everything
cargo expand my_module # expand a specific module

Here's a real debugging session. Say you wrote this:

macro_rules! make_fn {
    ($name:ident, $ret:ty, $body:expr) => {
        fn $name() -> $ret {
            $body
        }
    };
}

make_fn!(get_answer, i32, { 42 });

Running cargo expand shows:

fn get_answer() -> i32 {
    {
        42
    }
}

Notice the double braces - $body captured { 42 } as an expression (which is a block), and the transcriber wrapped it in another { }. It compiles and works, but if this were a more complex case, the extra nesting could cause issues. You'd catch this immediately in the expansion.

Another tool: trace_macros! on nightly. It prints each macro invocation as the compiler processes it:

#![feature(trace_macros)]

trace_macros!(true);
make_fn!(get_answer, i32, { 42 });
trace_macros!(false);

This outputs the expansion steps to stderr during compilation. Useful when you have macros calling macros and need to see the chain.

Scoping and visibility

macro_rules! macros have textual scope by default - they're visible from the point of definition to the end of their enclosing scope, like a let binding:

// greet!(); // ERROR: not yet defined

macro_rules! greet {
    () => { println!("hi"); };
}

greet!(); // OK

mod inner {
    // greet!(); // OK from parent scope
}

To make a macro available across crates, use #[macro_export]:

#[macro_export]
macro_rules! my_macro {
    () => {};
}

// Now accessible as crate::my_macro! or by path

The $crate metavariable

When your exported macro needs to reference items from its own crate, use $crate:

// In crate `my_utils`:
pub fn internal_helper() -> String {
    "helped".into()
}

#[macro_export]
macro_rules! do_thing {
    () => {
        $crate::internal_helper()
    };
}

Without $crate, the macro would try to find internal_helper in the caller's crate and fail. $crate always resolves to the crate where the macro is defined, regardless of where it's invoked. Use it in every #[macro_export] macro that references items from its own crate.

When macro_rules! is enough (and when it isn't)

macro_rules! handles:

You need proc macros when you need:

The practical dividing line: if you can describe your macro's input as a fixed set of patterns with repetitions, macro_rules! works. If you need to inspect the structure of a type definition (field names, field types, generics), reach for a proc macro.

There's a middle ground emerging. The Rust team is working on declarative macro improvements that would let macro_rules! handle derive and attribute positions, potentially eliminating the need for proc macros in many cases. Not stable yet, but worth watching.

Metavariable expressions (nightly)

RFC 3086 introduces metavariable expressions - metadata about repetitions. As of early 2026, these are still behind #![feature(macro_metavar_expr)] on nightly, but they solve real pain points:

#![feature(macro_metavar_expr)]

macro_rules! count_args {
    ( $( $x:expr ),* ) => {
        ${count(x)}  // number of repetitions, no recursion needed
    };
}

macro_rules! enumerate {
    ( $( $x:expr ),* ) => {
        [$( (${index()}, $x) ),*]
        // index() gives 0, 1, 2, ... for each repetition
    };
}

assert_eq!(count_args!(a, b, c), 3);
assert_eq!(enumerate!("a", "b", "c"), [(0, "a"), (1, "b"), (2, "c")]);

Without these, counting repetitions requires a recursive workaround with nested macro calls. ${count()} is a one-liner. ${index()} and ${length()} give you positional info inside repetitions. ${ignore(x)} expands to nothing but lets you repeat at the same depth as x.

The stabilization effort has stalled due to design questions around depth parameters, but the core functionality works and is widely used in nightly projects.

Putting it all together: patterns worth stealing

Here are a few more patterns that show up in real codebases.

Enum dispatch

macro_rules! dispatch {
    ($value:expr, $enum:ident, [ $( $variant:ident ),* ], $method:ident ( $($arg:expr),* )) => {
        match $value {
            $( $enum::$variant(inner) => inner.$method($($arg),*), )*
        }
    };
}

enum Shape {
    Circle(Circle),
    Rect(Rect),
}

// Instead of writing the match manually every time:
let area = dispatch!(shape, Shape, [Circle, Rect], area());

Newtype wrapper

macro_rules! newtype {
    ($name:ident, $inner:ty) => {
        #[derive(Debug, Clone, PartialEq, Eq, Hash)]
        pub struct $name($inner);

        impl $name {
            pub fn new(val: $inner) -> Self {
                Self(val)
            }

            pub fn into_inner(self) -> $inner {
                self.0
            }
        }

        impl std::fmt::Display for $name {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                write!(f, "{}", self.0)
            }
        }
    };
}

newtype!(UserId, String);
newtype!(Email, String);
newtype!(Port, u16);

// UserId and Email are now distinct types that won't accidentally mix

Compile-time map

macro_rules! static_map {
    ( $( $key:expr => $val:expr ),* $(,)? ) => {
        |key| match key {
            $( $key => Some($val), )*
            _ => None,
        }
    };
}

let status_text = static_map! {
    200 => "OK",
    404 => "Not Found",
    500 => "Internal Server Error",
};

assert_eq!(status_text(200), Some("OK"));
assert_eq!(status_text(418), None);

Common mistakes

Forgetting that repetition variables must be used at the same depth:

macro_rules! bad {
    ( $( $x:expr ),* ) => {
        // ERROR: $x is at depth 1, but used at depth 0
        println!("{}", $x);
    };
}

Matching too greedily:

macro_rules! too_greedy {
    ( $( $t:tt )* extra ) => { };
}

too_greedy!(a b c extra);
// The $($t:tt)* eats everything including "extra" - the arm never matches

tt is greedy. If you need a terminator, put distinctive syntax before the tail:

macro_rules! ok {
    ( $( $t:tt )* ; extra ) => { };
}

Infinite recursion in TT munchers:

If your base case doesn't match, the macro recurses forever until hitting the recursion limit (128 by default). Always test your base case first. You can raise the limit with #![recursion_limit = "256"], but if you need to, your macro probably needs a redesign.

Where to go from here

The Little Book of Rust Macros is the definitive reference. It covers patterns I didn't have space for here - push-down accumulation, callbacks, internal rules with @ prefixes, and more.

For the standard library's own macros, read the source. vec! is straightforward. println! chains through several layers. assert_eq! uses most of the techniques from this post. Reading working macros in real codebases teaches you patterns faster than any tutorial.

And when macro_rules! hits its limits - when you need to inspect struct fields, parse custom attributes, or generate code from external schemas - that's when proc macros enter the picture. But that's a different post.