Rust Deep Dives #7: Declarative Macros Aren't As Scary As They Look
Post 7 of 8 in Rust Deep Dives. Companion series: Rust Patterns That Matter.
Previous: #6: Serde Patterns | Next: #8: Cargo Features
Declarative macros have a reputation. People see $($thing:tt)* and
assume they've wandered into a dark corner of the language best left to library
authors. That's a shame, because macro_rules! is one of the most
practical tools in Rust once you understand the handful of ideas it's built on.
This post walks through those ideas, shows macros you'd actually write, and talks
about when to stop.
When a function isn't enough
Most of the time, a function is exactly what you want. Functions are typed, they show up in stack traces, they're easy to read. But there are a few situations where functions hit a wall.
The first is variadic arguments. Rust functions take a fixed number
of parameters. If you want to accept two items, or five, or twenty, you need a
different approach. This is why vec![1, 2, 3] is a macro — there's
no function signature that accepts "any number of expressions separated by commas."
The second is boilerplate reduction across types. If you're implementing the same trait for ten types and the body is identical except for the type name, a function can't help you. You need something that operates on syntax, not values.
The third is code that needs to capture context. A macro like
file!() or line!() embeds information from the call site.
A function call can't know where it was called from without you passing that
information explicitly.
That's the key distinction: functions operate on values at runtime, macros operate on tokens at compile time. A macro receives a stream of syntax tokens, matches them against patterns, and produces new tokens that the compiler then processes as normal Rust code. Nothing happens at runtime. By the time your program runs, every macro has already been expanded into regular Rust.
The anatomy of a macro
Let's look at a macro you've probably wished existed: a hashmap! literal.
The standard library gives you vec![] but no equivalent for maps. Here's
how you'd write one:
macro_rules! hashmap {
($($key:expr => $value:expr),* $(,)?) => {
{
let mut map = std::collections::HashMap::new();
$(
map.insert($key, $value);
)*
map
}
};
}
There's a lot packed into those few lines. Let's take it apart.
The first line, macro_rules! hashmap, declares the macro's name. When
someone writes hashmap!, this is what the compiler looks up.
Inside the curly braces is a single rule (macros can have multiple
rules, but this one only needs one). Every rule has two parts separated by =>:
a matcher on the left and an expansion (sometimes
called "transcriber") on the right.
The matcher is ($($key:expr => $value:expr),* $(,)?). Reading from the
inside out: $key:expr means "capture any expression and bind it to
$key." The :expr part is a fragment specifier
— it tells the macro what kind of syntax to expect. Then => is
a literal token that must appear between each key and value. The $(...),*
wrapper means "repeat this pattern zero or more times, separated by commas." And
$(,)? allows an optional trailing comma.
The expansion is the right side of =>. It's the code that gets produced.
The outer { ... } creates a block expression so the macro can declare
a local variable and return it. Inside, $( map.insert($key, $value); )*
repeats the insert call once for each captured key-value pair.
Using it looks exactly like you'd hope:
let scores = hashmap! {
"alice" => 10,
"bob" => 20,
"carol" => 30,
};
After expansion, the compiler sees a block that creates a HashMap, inserts
three entries, and returns it. No runtime cost beyond what you'd write by hand.
The fragment specifiers
Fragment specifiers tell the macro parser what kind of token tree to expect in each position. Here are the ones you'll actually use:
expr — expressions
The most common specifier. Matches any Rust expression: a literal, a function call, a block, an arithmetic operation, anything that produces a value.
macro_rules! double {
($e:expr) => {
$e + $e
};
}
let x = double!(5); // 10
let y = double!(2 + 3); // 10, not 7 — expr is parsed as a unit
That second example is important. Because expr captures the entire
expression 2 + 3 as one unit, the expansion is (2 + 3) + (2 + 3),
not 2 + 3 + 2 + 3. This is one of the ways macro_rules!
avoids the classic C-macro pitfalls.
ident — identifiers
Matches a single identifier: a variable name, a type name, a function name. This is what you use when the macro needs to generate names.
macro_rules! make_getter {
($field:ident, $ty:ty) => {
fn $field(&self) -> &$ty {
&self.$field
}
};
}
ty — types
Matches a type expression: u32, String,
Vec<u8>, &'a str. You saw it in the getter example
above. It's essential whenever your macro needs to work with a type that the caller
specifies.
tt — token tree
The most flexible specifier. Matches a single token or a balanced group of tokens in
parentheses, brackets, or braces. When you don't know what kind of syntax to expect,
tt is the escape hatch. The $($t:tt)* pattern matches
literally anything.
macro_rules! pass_through {
($($t:tt)*) => {
$($t)*
};
}
pass_through!(let x = 42;); // expands to: let x = 42;
literal, pat, path, stmt, block, item
These are more specialized. literal matches literal values like
42 or "hello". pat matches patterns (as in
match arms). path matches type paths like
std::io::Error. stmt matches statements.
block matches { ... } blocks. And item
matches top-level items like function definitions or struct declarations.
In practice, you'll use expr, ident, ty, and
tt for 90% of macros. The others exist for when you need precision.
Repetition patterns
Repetition is where macros pull ahead of functions. The syntax is
$(...) sep rep, where sep is an optional separator token
and rep is either * (zero or more) or + (one
or more).
// Zero or more expressions, separated by commas
($($e:expr),*)
// One or more identifiers, separated by commas
($($name:ident),+)
// Key-value pairs separated by commas, trailing comma allowed
($($k:expr => $v:expr),* $(,)?)
In the expansion, you use the same $(...)* or $(...)+
syntax to repeat the output for each captured element. The number of repetitions
in the expansion must match the number in the matcher.
macro_rules! print_all {
($($item:expr),+) => {
$(
println!("{}", $item);
)+
};
}
print_all!("hello", 42, 3.14);
// Expands to:
// println!("{}", "hello");
// println!("{}", 42);
// println!("{}", 3.14);
Nested repetition works too. If your matcher has $( $($inner:expr),+ );+,
the expansion can nest the $( )+ blocks the same way. This gets complex
fast, which is why you should keep macros as simple as you can.
macro_rules! matrix {
( $( [ $($val:expr),* ] ),+ $(,)? ) => {
vec![
$(
vec![$($val),*],
)+
]
};
}
let m = matrix![
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
// m is Vec<Vec<i32>>
Multiple arms
Just like a match expression, a macro can have multiple rules. The
compiler tries them in order and uses the first one that matches. This lets you
give a single macro different calling conventions.
macro_rules! log {
// log!("message")
($msg:expr) => {
eprintln!("[LOG] {}", $msg);
};
// log!("user: {}", name)
($fmt:expr, $($arg:expr),+) => {
eprintln!(concat!("[LOG] ", $fmt), $($arg),+);
};
// log!(Level::Warn, "something happened")
($level:expr, $fmt:expr $(, $arg:expr)*) => {
eprintln!(concat!("[{:?}] ", $fmt), $level $(, $arg)*);
};
}
Order matters. If you put the most general pattern first, it'll match everything and the specific patterns will never be reached. Put the most specific patterns first, and fall through to the general one at the end. Think of it like function overloading, but resolved at compile time by pattern matching on token structure.
A common idiom is using a "base case + recursive case" pattern for macros that need to process items one at a time:
macro_rules! count {
() => { 0 };
($head:tt $($tail:tt)*) => {
1 + count!($($tail)*)
};
}
let n = count!(a b c d); // 4
Four practical macros you'd actually write
1. Collection literals: hashmap! and btreemap!
We already saw hashmap!. Here's the same idea for BTreeMap,
and a slightly more polished version that pre-allocates capacity:
macro_rules! hashmap {
($($key:expr => $value:expr),* $(,)?) => {{
let _cap = count!($($key)*);
let mut map = std::collections::HashMap::with_capacity(_cap);
$( map.insert($key, $value); )*
map
}};
}
macro_rules! btreemap {
($($key:expr => $value:expr),* $(,)?) => {{
let mut map = std::collections::BTreeMap::new();
$( map.insert($key, $value); )*
map
}};
}
let users = hashmap! {
1 => "alice",
2 => "bob",
3 => "carol",
};
let sorted = btreemap! {
"z" => 26,
"a" => 1,
"m" => 13,
};
These macros save a few lines each time you create a map literal. That doesn't sound like much, but in test code where you're constructing expected values constantly, the noise reduction adds up.
2. ensure! — like assert! but returns Err
The assert! macro panics on failure. That's fine in tests, but in
application code you usually want to return an error instead. The ensure!
pattern (used by the anyhow crate) does exactly that:
macro_rules! ensure {
($cond:expr, $msg:expr) => {
if !$cond {
return Err(format!($msg).into());
}
};
($cond:expr, $fmt:expr, $($arg:expr),+) => {
if !$cond {
return Err(format!($fmt, $($arg),+).into());
}
};
}
A function can't do this because return in a function body returns
from that function. In a macro expansion, return returns from
the function at the call site. That's a fundamental capability that only macros have.
fn parse_port(s: &str) -> Result<u16, Box<dyn std::error::Error>> {
let port: u16 = s.parse()?;
ensure!(port > 0, "port must be positive");
ensure!(port <= 65535, "port {} out of range", port);
Ok(port)
}
3. impl_display! for multiple types
If you have several newtypes and you want them all to display their inner value, the trait implementation is identical for each one. Writing it out five times is tedious. A macro handles the repetition:
macro_rules! impl_display {
($($t:ty),+ $(,)?) => {
$(
impl std::fmt::Display for $t {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
)+
};
}
struct UserId(u64);
struct OrderId(u64);
struct SessionToken(String);
impl_display!(UserId, OrderId, SessionToken);
This is one of the most common uses of macros in real codebases. Any time you're
copy-pasting a trait impl and only changing the type name, a macro is the right call.
The standard library does this extensively — look at how numeric traits are
implemented for u8, u16, u32, etc.
4. Builder field macro
Builder patterns involve a lot of nearly-identical setter methods. A macro can generate them:
macro_rules! builder_field {
($name:ident, $ty:ty) => {
pub fn $name(mut self, value: $ty) -> Self {
self.$name = Some(value);
self
}
};
}
struct ServerConfig {
host: Option<String>,
port: Option<u16>,
max_connections: Option<usize>,
timeout_ms: Option<u64>,
}
impl ServerConfig {
fn new() -> Self {
ServerConfig {
host: None,
port: None,
max_connections: None,
timeout_ms: None,
}
}
builder_field!(host, String);
builder_field!(port, u16);
builder_field!(max_connections, usize);
builder_field!(timeout_ms, u64);
}
let config = ServerConfig::new()
.host("localhost".into())
.port(8080)
.max_connections(100)
.timeout_ms(5000);
Each builder_field! invocation expands to a complete setter method.
Four lines of macro calls replace sixteen lines of nearly-identical methods. And
if you need to change the setter convention later (say, taking impl Into<T>
instead of T), you change it once in the macro definition.
Debugging macros
Macros are harder to debug than functions. When something goes wrong, the compiler error points at the macro call site, and the message often references code you didn't write. Here are the tools that help.
cargo expand
The single most useful tool for macro debugging. Install it with
cargo install cargo-expand, then run:
# Expand all macros in your crate
cargo expand
# Expand macros in a specific module
cargo expand module_name
# Expand macros in a specific test
cargo expand --test test_name
This shows you exactly what your macro expanded to. If the compiler is complaining
about a type mismatch inside a macro, cargo expand will show you the
generated code so you can see where the types don't line up.
trace_macros!
A nightly-only feature that prints each macro invocation and its expansion during compilation:
#![feature(trace_macros)]
trace_macros!(true);
let m = hashmap! { "a" => 1 };
trace_macros!(false);
This is especially helpful for recursive macros where you want to see each step of the expansion.
Testing macro output
Don't just test that macros compile — test that they produce the right results. Write regular unit tests that invoke the macro and check the output:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hashmap_macro_creates_correct_map() {
let map = hashmap! {
"a" => 1,
"b" => 2,
};
assert_eq!(map.len(), 2);
assert_eq!(map["a"], 1);
assert_eq!(map["b"], 2);
}
#[test]
fn hashmap_macro_handles_empty() {
let map: std::collections::HashMap<&str, i32> = hashmap! {};
assert!(map.is_empty());
}
#[test]
fn hashmap_macro_trailing_comma() {
let map = hashmap! { "x" => 1, };
assert_eq!(map.len(), 1);
}
}
Test edge cases: empty invocations, trailing commas, single items, expressions with side effects. If your macro does something subtle with evaluation order or hygiene, write a test that catches it.
When to stop
Macros are powerful, but they have real costs. Error messages get worse. Code navigation tools struggle. Your colleagues (and future you) have to mentally expand the macro to understand what it does. Here are the guidelines I use.
If a function works, use a function. This is the most important rule.
Generics, trait bounds, and impl Trait solve most problems people reach
for macros to handle. Before writing a macro, ask: "Can I do this with generics?"
If yes, do that instead. Functions give you type checking, IDE support, and
documentation that macros don't.
If your macro is more than about 30 lines, consider a proc macro. Declarative macros are great for small patterns, but they weren't designed for generating large amounts of code or doing complex transformations. Proc macros (derive macros, attribute macros) give you the full power of Rust at compile time. They're harder to set up — they need their own crate — but the code is regular Rust, not pattern-matching soup.
If only you can read it, it's too clever. A macro that saves ten
lines but requires five minutes to understand isn't saving anything. The best macros
are the ones where the call site is obvious even if you've never seen the
definition: hashmap!{ "a" => 1 } is self-explanatory,
impl_display!(Foo, Bar, Baz) is self-explanatory. If your macro's
call site requires a comment explaining what it does, rethink the design.
Don't use macros for control flow unless it's really worth it.
Macros that contain return, break, or continue
are surprising. The ensure! pattern is widely understood enough to get
a pass, but in general, hidden control flow makes code harder to follow. If someone
reading your function can't see the return statements, they can't
reason about the function's behavior.
Don't nest macros that generate macros. It's possible. It works. It's
also one of the most unreadable things you can do in Rust. If you find yourself
writing macro_rules! inside a macro_rules! expansion, step
back and find another approach.
Declarative macros are a tool, and like any tool, the skill is knowing when to
pick it up and when to leave it on the shelf. For reducing boilerplate across types,
for variadic APIs, for small DSLs that make call sites cleaner — reach for
macro_rules!. For everything else, a function is almost certainly the
right answer.
Telex