Chapter 26: Macros in Rust
Metaprogramming—writing code that generates or manipulates other code—is a powerful tool in systems programming. Even in the earliest chapters of this book, we used println!, vec!, and assert_eq!, all of which end with an exclamation mark (!). In Rust, this suffix signals that you are invoking a macro rather than a regular function. We have also used attributes like #[derive(Debug, Clone)], which automatically generate trait implementations at compile time using macros behind the scenes.
While Rust provides strong tools for abstraction through functions, generics, and traits, these mechanisms operate within strict syntactic and type-system boundaries. Functions must have a fixed number of parameters with predetermined types (or generic constraints), and they cannot inspect or generate new syntax, types, or identifiers. Macros operate at an earlier stage of compilation: they take a stream of syntax tokens as input and expand them into valid Rust syntax before the compiler checks types, ownership, and lifetimes.
This chapter explores what macros are, how Rust’s macro system compares to metaprogramming in C and other languages, when macros are beneficial—and when they should be avoided—and how to write both declarative macros (macro_rules!) and procedural macros.
26.1 What Are Macros and Why Does Rust Need Them?
At its core, a macro is a compile-time transformation rule: it accepts fragments of source code as input and produces expanded source code as output. Because this expansion occurs during compilation before final type checking and code generation, macros incur zero runtime overhead beyond the code they generate.
26.1.1 Capabilities Beyond Functions and Generics
A natural question for experienced programmers is: given that Rust already has generic functions, traits, and closures, why are macros necessary at all? In Rust, macros fulfill several crucial roles that functions and methods cannot:
- Variadic Arguments: Safe Rust functions do not support a variable number of arguments (only
extern "C"functions for FFI do), nor do they support function overloading or default parameter values. Macros likeprintln!("x = {}, y = {}", x, y)orvec![1, 2, 3, 4]can accept an arbitrary number of arguments of potentially different types. - Compile-Time Format and Syntax Validation: The
println!andformat!macros do not parse their format strings at runtime like C’sprintf. Instead, through the underlying format_args! compiler built-in macro, Rust inspects the string literal at compile time, verifies that the number and types of arguments match the placeholders, and expands them into direct, type-safe formatting calls. A mismatched placeholder causes a compile-time error rather than undefined behavior at runtime. - Eliminating Repetitive Boilerplate Across Unrelated Types: Sometimes you need to implement a trait or define similar methods for a dozen primitive types (
u8,u16,u32,u64,i32,f64, etc.) where no single trait bound covers the exact operations needed, or where you are implementing a foundational trait itself. A macro can generate all of these implementations in a few lines. - Generating Code from Type Definitions: Using
#[derive(Debug, PartialEq, Serialize)], macros can inspect the fields or variants of astructorenumand automatically generate the correspondingimplblocks. Without macros, developers would have to maintain thousands of lines of error-prone serialization, comparison, or formatting boilerplate by hand. - Control Flow in the Caller’s Context: A regular function cannot directly cause its caller to
return,break, orcontinue. Because a macro expands inline at the call site, it can expand into control-flow statements affecting the surrounding function. (Before the?operator was added to the language, Rust used thetry!(...)macro to unwrap aResultor return anErrearly from the calling function.) - Domain-Specific Languages (DSLs): Macros allow libraries to embed expressive, declarative mini-languages directly inside Rust code—such as HTML templating (
html! { <div class="box">{ title }</div> }), compile-time checked SQL queries (sqlx::query!("SELECT id FROM users WHERE active = true")), or JSON literals (serde_json::json!({ "name": "Alice", "scores": [10, 20] })).
26.2 Macros Across Programming Languages
To appreciate Rust’s design choices, it helps to examine how metaprogramming is handled across the programming language landscape.
26.2.1 Languages Without Macros
Many popular languages—such as Go, Java, Python, C#, and JavaScript/TypeScript—do not have compile-time syntactic macros. Instead, they address similar problems through other mechanisms:
- Runtime Reflection and Dynamic Typing: In Python or JavaScript, functions can inspect object attributes, dynamically construct types, and accept
*argsand**kwargsat runtime. Java and C# rely heavily on runtime reflection and bytecode inspection to implement serialization, dependency injection, and ORM frameworks. - External Code Generation: Go intentionally omits macros to keep the language grammar simple and compilation fast. When boilerplate generation is unavoidable, Go developers rely on runtime reflection (
reflect) or external command-line tools invoked viago generatebefore compilation.
Why doesn’t Rust take the reflection or dynamic approach? As a systems programming language committed to zero-cost abstractions and minimal binary footprints, Rust does not bundle rich runtime type metadata (reflection) into compiled binaries, nor does it use a virtual machine or garbage collector. Moving code generation and structural inspection to compile time via macros preserves both static type safety and C-level runtime performance.
26.2.2 C’s Text-Substitution Preprocessor (#define)
For C and C++ programmers, the word “macro” immediately brings to mind the C preprocessor (cpp) and #define directives. However, C macros are not integrated into the compiler’s syntax tree; they perform blind textual substitution prior to compilation. This design has notorious pitfalls:
- Operator Precedence Traps: Because the C preprocessor merely pastes text, expressions can bind in unexpected ways unless every parameter and the entire macro body are defensively wrapped in parentheses:
#define SQUARE(x) x * x
int result = SQUARE(1 + 2); // Expands to: 1 + 2 * 1 + 2 = 5 (not 9!)
- Multiple Evaluation of Side Effects: If an argument passed to a C macro has side effects, those side effects are executed every time the parameter appears in the expansion:
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int x = 5, y = 10;
int m = MAX(x++, y++); // Expands to: ((x++) > (y++) ? (x++) : (y++))
// y is incremented twice!
- Lack of Hygiene (Name Collisions): If a C macro declares a temporary local variable (e.g.,
int temp = ...;), and the caller happens to pass a variable also namedtempas an argument, the macro silently shadows the caller’s variable, causing subtle bugs:
#define SWAP(a, b) do { int temp = (a); (a) = (b); (b) = temp; } while (0)
int temp = 1, other = 2;
SWAP(temp, other); // Inner `temp` shadows outer `temp` -- broken!
- Multi-Statement Control-Flow Bugs: A multi-statement C macro that is not carefully wrapped in
do { ... } while (0)can breakif/elsecontrol flow at the call site:
#define LOG_AND_INC(x) printf("inc\n"); (x)++;
if (cond)
LOG_AND_INC(count); // Only `printf` is inside the `if`; `count++` always runs!
- No Scope or Module Boundaries: A
#definedirective remains active from the point of definition until the end of the translation unit (or#undef), ignoring C’s block scopes and namespaces. A macro defined in a header file can accidentally rewrite unrelated identifiers in files that include it (such as Windows headers notoriously#define-ingminandmax). - Poor Diagnostics and Type Blindness: The C preprocessor knows nothing about C types or grammar. Syntax errors inside macros often produce baffling compiler errors pointing to the macro call site without clear context.
26.2.3 Smart Metaprogramming: Rust, Lisp, Nim, and Zig
Modern languages that support compile-time metaprogramming reject raw text substitution in favor of syntactic or semantic transformations:
- Lisp and Scheme: The pioneers of syntactic macros. Because Lisp code is written directly as data structures (S-expressions), macros manipulate the Abstract Syntax Tree (AST) directly at compile time.
- Nim: Offers one of the most ergonomic and powerful metaprogramming systems among compiled systems languages. Nim provides both lightweight hygienic
templates and full ASTmacros written in ordinary Nim syntax, capable of inspecting types as well as constructing and transforming syntax trees. - Zig: Takes a distinct, minimalist approach by eliminating macros entirely in favor of
comptime. In Zig, generics, conditional compilation, and type reflection are written as ordinary Zig code marked withcomptime, executed at compile time with types treated as first-class compile-time values. This avoids learning a separate macro sub-language while preventing hidden control flow. - C++: Gradually moved away from
#definemacros by introducing templates,constexpr,consteval, variadic templates, and (in C++26) static compile-time reflection, though the C preprocessor remains for backward compatibility. - Rust: Provides two complementary, compiler-integrated macro systems that operate on token trees and AST fragments rather than raw text:
- Declarative macros (
macro_rules!): Pattern-matching macros that match syntactic structures (expressions, types, identifiers) and expand into hygienic Rust code. - Procedural macros: Rust functions executed at compile time that consume and produce streams of syntax tokens (
TokenStream), powering#[derive(...)], custom attributes, and complex DSLs.
26.2.4 Advantages of Rust’s Smart Macros over C Macros
Compared to C’s #define, Rust macros offer fundamental safety guarantees:
- Syntactic Awareness (No Precedence Bugs): When a Rust macro captures an expression (
$x:expr), the compiler treats it as a single, self-contained AST node. Even if$xis1 + 2, multiplying$x * $xinside the macro expands as(1 + 2) * (1 + 2), evaluating to9. - Macro Hygiene: By default, local variables introduced inside a Rust
macro_rules!expansion live in a separate “syntax context” (color) from variables at the call site. A temporary variablelet temp = ...;inside a macro cannot accidentally shadow or collide with a variable namedtempin the caller’s scope. - Module Integration: Rust macros respect visibility and module namespaces (
use my_crate::my_macro;), rather than polluting the global namespace. - Post-Expansion Type Checking and Borrow Checking: Code generated by a Rust macro must still pass the exact same type checker, trait resolver, and borrow checker as hand-written Rust code.
26.3 Disadvantages and Trade-offs of Rust Macros
Before diving into writing macros, it is important to recognize their downsides. Seasoned Rust developers follow a simple rule of thumb: if a problem can be solved cleanly with a regular function, const fn, generic type, or trait, do not use a macro.
- Increased Compile Times: Macros—especially procedural macros—can significantly slow down builds. Procedural macros must be compiled into a separate crate (
proc-macro = true) and executed by the compiler during compilation. Furthermore, most procedural macros rely on large parsing and code-generation crates likesynandquote, which add noticeable compilation overhead. - Degraded IDE and Tooling Support: Because macros can invent arbitrary syntax and generate new items on the fly, IDE tools like
rust-analyzerand code formatters likerustfmtsometimes struggle inside macro invocations. Autocompletion, “go to definition,” automated refactoring, and automatic formatting often work less reliably inside complex macros than in standard Rust code. (rustfmt, for instance, usually will not format custom DSL syntax inside braces or brackets.) - Reduced Readability and Maintainability: Declarative macros (
macro_rules!) use a specialized pattern-matching syntax filled with$,:, and repetition operators ($(...)*) that differs from ordinary Rust code. Procedural macros manipulate token streams rather than typed ASTs directly, making them verbose to write and maintain. - Complex Error Messages: While Rust’s compiler diagnostics for macros are far superior to C’s preprocessor errors, deeply nested macros or mismatched token patterns can still produce intimidating error messages.
- Binary Code Bloat: Because macros expand inline at every call site, expanding a large macro dozens of times duplicates all of its generated code, potentially increasing binary size and instruction-cache pressure (similar to excessive function inlining or template monomorphization).
26.4 Standard Library and Compiler Built-in Macros
You have already encountered several macros from the standard library throughout this book. However, not all macros provided with Rust are created equal—they fall into two distinct categories:
- Standard Library Macros: Written in Rust (mostly using
macro_rules!) insidecoreorstd. You could write your own equivalents of macros likevec!,matches!,assert!, ordbg!. - Compiler Built-in Macros: Implemented directly inside the
rustccompiler itself. If you inspect their definitions in the Rust standard library source code, you will find an internal#[rustc_builtin_macro]attribute and an empty body (/* compiler built-in */). They cannot be written as ordinary user-defined macros because they inspect compiler state—such as source file coordinates, build environment variables, or the host filesystem—that is inaccessible to regular macros.
26.4.1 Common Standard Library Macros
These macros are everyday utilities implemented in the standard library (often building on top of compiler built-ins):
| Macro | Purpose |
|---|---|
println!, print!, eprintln!, eprint! | Format and print text to stdout or stderr with compile-time format string validation (powered internally by the compiler built-in format_args!). |
format! | Format text into a heap-allocated String. |
write!, writeln! | Write formatted text into a buffer or stream implementing std::fmt::Write or std::io::Write. |
vec! | Create a Vec<T> from a list of elements (vec![1, 2, 3]) or a repeated value (vec![0; 100]). |
assert!, assert_eq!, assert_ne! | Verify runtime conditions and panic with detailed diagnostics on failure. (debug_assert!* variants are only enabled in debug builds.) |
panic! | Terminate the current thread (or abort) with a formatted error message and source location. |
todo!, unimplemented! | Mark unfinished code paths; satisfy any expected return type (!) while panicking if reached at runtime. |
unreachable! | Mark code paths that the programmer knows are logically unreachable. |
matches! | Test whether an expression matches a pattern, returning a bool (e.g., matches!(val, Some(1..=5))). |
dbg! | Print the file, line number, expression source text, and evaluated value to stderr, then return ownership of the value. |
thread_local! | Declare thread-local storage keys (std::thread::LocalKey), analogous to C11’s _Thread_local. |
26.4.2 Compiler Built-in Intrinsics (line!, file!, stringify!, and Friends)
Because compiler built-in macros are evaluated directly by rustc, they serve many of the same roles as C’s predefined macros (__FILE__, __LINE__), the # stringification operator, #error, and C23’s #embed—but with full type safety:
| Built-in Macro | Return Type | Purpose and C Equivalent |
|---|---|---|
file!() | &'static str | Source file path of the invocation (like C’s __FILE__). |
line!(), column!() | u32 | 1-based line and column numbers of the invocation (like C’s __LINE__). |
module_path!() | &'static str | Current Rust module path (e.g., "my_crate::network::tcp"). |
stringify!(...) | &'static str | Converts arbitrary input tokens into a string literal at compile time without evaluating them (like C’s #x preprocessor operator). |
concat!(...) | &'static str | Concatenates comma-separated literals ("v", 1, '.', 0) into a single compile-time &'static str literal. |
cfg!(...) | bool | Evaluates a conditional compilation predicate (e.g., cfg!(target_os = "linux")) to a bool constant at compile time. |
env!("VAR"), option_env!("VAR") | &'static str / Option<&'static str> | Reads an environment variable at compile time (env! fails compilation if the variable is unset; option_env! returns None). |
include_str!("path"), include_bytes!("path") | &'static str / &'static [u8] | Embeds a UTF-8 text file or raw binary file from disk directly into the executable at compile time (like C23’s #embed). |
include!("path") | (AST items / expr) | Parses and inserts Rust source code from another file (typically used to include code generated by a build.rs script). |
compile_error!("msg") | ! | Immediately fails compilation with a custom error message (like C’s #error directive). |
One subtle and very useful property of file!(), line!(), and column!() is how they behave when used inside your own macros: instead of reporting the line number where the macro was defined, they expand to the file, line, and column of the outermost call site where the macro was invoked.
This makes it easy to understand how the standard library’s dbg! macro works under the hood—it combines file!(), line!(), and stringify!() inside a regular macro_rules! definition:
fn main() {
let a = 10;
// 1. Inspecting source coordinates and token stringification directly:
println!(
"At {}:{}:{} (in module `{}`)",
file!(),
line!(),
column!(),
module_path!()
);
println!(
"Expression `{}` evaluates to {}",
stringify!((a * 2) + 5),
(a * 2) + 5
);
// 2. Compile-time literal concatenation:
const VERSION_TAG: &str = concat!("build-", 2026, "-", true);
assert_eq!(VERSION_TAG, "build-2026-true");
// 3. `dbg!` combines `file!()`, `line!()`, and `stringify!()` internally,
// printing the source expression and its result while returning ownership:
let b = dbg!((a * 2) + 5) + 1;
println!("Final b = {b}");
}
Tip (
#[track_caller]vs. Macros for Caller Location): In C, logging or assertion functions that need the caller’s__FILE__and__LINE__must be wrapped in a macro. In Rust, if your only reason for writing a macro is to capture the caller’s source location, you can often use a regular function annotated with#[track_caller]and callstd::panic::Location::caller()inside it instead!
26.5 Declarative Macros (macro_rules!)
Declarative macros, defined with macro_rules!, are the most common way to write custom macros in Rust. They work similarly to a match expression, except that instead of matching runtime values against data patterns, they match compile-time Rust syntax fragments against token patterns and emit replacement code.
26.5.1 Basic Syntax and Mechanics
A macro_rules! definition consists of a name and one or more rules (arms) of the form (pattern) => { expansion };:
// Define a simple declarative macro
macro_rules! say_hello {
// Arm 1: matches an empty invocation `say_hello!()`
() => {
println!("Hello from a macro!");
};
// Arm 2: matches a single expression `say_hello!("Alice")`
($name:expr) => {
println!("Hello, {}!", $name);
};
}
fn main() {
say_hello!();
say_hello!("C Programmer");
}
Key observations:
- Top-to-Bottom Matching: Just like
matcharms,macro_rules!arms are tried in order from top to bottom. You should place more specific patterns before more general fallback patterns. - Metavariables and Fragment Specifiers: Inside a pattern, variables start with a dollar sign (
$name) followed by a colon and a fragment specifier (:expr), which tells the compiler what kind of Rust syntax construct to match. Inside the expansion block,$nameis substituted wherever it appears (without repeating:expr). - Flexible Delimiters at the Call Site: You can invoke any Rust macro using parentheses
say_hello!(...), bracketssay_hello![...], or bracessay_hello!{...}. By convention,vec![...]uses brackets, statement-like or multi-line DSL blocks often use braces, and most other macros use parentheses. Note one syntactic nuance: when a macro is invoked at statement level with curly braces (e.g.,say_hello! { "Alice" }), Rust does not require a trailing semicolon, whereassay_hello!("Alice");andsay_hello!["Alice"];do.
26.5.2 Avoiding C’s Pitfalls: Precedence and Single Evaluation
Suppose we want to write a Rust equivalent of C’s SQUARE(x) macro. A first attempt might look like this:
macro_rules! square_naive {
($x:expr) => {
$x * $x
};
}
How does this behave compared to C’s #define SQUARE(x) x * x?
- Operator precedence is already safe: Because
$xis matched with the:exprfragment specifier, the compiler captures1 + 2insquare_naive!(1 + 2)as a single, self-contained AST expression node rather than raw text. Even without parentheses around$x,square_naive!(1 + 2)evaluates as(1 + 2) * (1 + 2) = 9(never1 + 2 * 1 + 2 = 5). - Multiple evaluation still occurs: Because
$xis substituted twice in$x * $x, the expression AST node itself is duplicated and executed twice at runtime. If$xis a function call likecompute_value()or a block with side effects like{ counter += 1; counter }, that code will run twice.
To avoid evaluating $x more than once, we bind $x to a local variable once inside a block expression before multiplying:
macro_rules! square {
($x:expr) => {{
let val = $x; // Evaluate $x exactly once!
val * val
}};
}
fn main() {
// 1. Operator precedence is preserved automatically because `1 + 2`
// is parsed as a single expression node (`:expr`), not raw text.
let a = square!(1 + 2);
assert_eq!(a, 9);
// 2. Side effects are evaluated only once because we bind `let val = $x;`.
let mut counter = 3;
let b = square!({
counter += 1;
counter
});
assert_eq!(b, 16);
assert_eq!(counter, 4); // Incremented only once!
}
Notice the double curly braces {{ ... }} in the expansion of square!. The outer braces delimit the macro arm’s body, while the inner braces create a block expression { let val = $x; val * val } in the expanded code. Wrapping multi-statement expression macros in a block expression is the idiomatic Rust equivalent of C’s do { ... } while (0) idiom—with the added advantage that a Rust block expression produces a return value!
26.5.3 Fragment Specifiers
When capturing metavariables in macro_rules!, you must specify the syntactic category (fragment specifier) that the variable matches:
| Specifier | Matches | Examples |
|---|---|---|
expr | Any valid Rust expression (in Rust 2024, also includes const { ... } and _) | 2 + 2, foo(), if c { a } else { b } |
expr_2021 | Expressions matching pre-2024 rules (excludes const blocks and _) | 2 + 2, foo() |
ident | An identifier or keyword | x, MyStruct, calculate |
ty | A type | i32, Vec<String>, &'a mut [u8] |
pat | A pattern (including ` | ` alternatives in Rust 2021+) |
pat_param | A single pattern without top-level ` | ` alternatives |
stmt | A statement (without trailing semicolon required) | let x = 5 |
block | A brace-delimited block of statements | { let a = 1; a + 1 } |
item | A top-level or module item | fn foo() {}, struct Bar;, impl ... |
path | A TypeStyle path | std::collections::HashMap, super::Foo |
literal | A literal value (including negative numbers) | 42, -1, "hello", 3.14, 'a' |
lifetime | A lifetime token | 'a, 'static |
vis | A visibility qualifier (possibly empty) | pub, pub(crate), (empty) |
meta | Contents of an attribute | derive(Debug), cfg(target_os = "linux") |
tt | A single Token Tree (a single token, or a balanced group (...), [...], {...}) | +, foo, (a + b * c) |
Note on the Rust 2024 Edition (
exprvs.expr_2021): In the Rust 2024 edition, theexprfragment specifier was updated to also match inlineconst { ... }blocks and_expressions. If you are migrating an existing macro from an earlier edition that relied on a separate arm matchingconstor_before a fallback:exprarm, you can use:expr_2021for backward compatibility.
Follow-Set Restrictions and Opaque AST Nodes
To ensure that future versions of Rust can expand the language grammar without breaking existing macros, macro_rules! enforces follow-set restrictions on certain fragment specifiers:
:exprand:stmtmay only be followed in a pattern by=>,,, or;(or by a closing delimiter),],}). For example, a pattern like($x:expr -> $y:expr)is rejected because->is not in the allowed follow set of:expr(though($x:expr => $y:expr)is valid!).:tyand:pathmay only be followed by=>,,,=,|,;,:,>,>>,[,{,as,where, or a block/token-tree metavariable.:pat(which allows top-level|alternatives) cannot be followed directly by|; if you need to use|as a separator after a pattern in your macro (such as($p:pat_param | $e:expr)), use:pat_paraminstead.:ident,:tt,:literal,:block, and:itemhave no follow-set restrictions and can be followed by any token.
Furthermore, once tokens are captured by any fragment specifier other than :ident, :lifetime, or :tt, they become an opaque AST node. If a macro forwards $x:expr to a second helper macro, that helper macro can match it against another :expr or :tt, but cannot decompose its internal tokens any further.
26.5.4 Repetition Patterns: Variadic Macros
One of the most useful features of macro_rules! is matching a variable number of arguments using the repetition syntax $( ... )sep rep:
$( ... )encloses the pattern or expansion to repeat.sepis an optional separator token, most commonly,or;.repis the repetition operator:*means zero or more repetitions.+means one or more repetitions.?means zero or one occurrence (optional; cannot take a separator token).
Let us look at a practical example: creating a hash_map! macro that initializes a HashMap with concise key => value syntax:
use std::collections::HashMap;
// A macro to initialize a HashMap using `key => value` syntax
macro_rules! hash_map {
// Match zero or more `key => value` pairs separated by commas.
// The trailing `$(,)?` optionally allows a trailing comma!
( $( $key:expr => $val:expr ),* $(,)? ) => {{
let mut map = HashMap::new();
// Repeat the insertion for every matched ($key, $val) pair
$(
map.insert($key, $val);
)*
map
}};
}
fn main() {
let scores = hash_map! {
"Alice" => 95,
"Bob" => 82,
"Charlie" => 88,
};
println!("Scores: {:?}", scores);
assert_eq!(scores.get("Alice"), Some(&95));
// Works with an empty invocation too because we used `*` (zero or more):
let empty: HashMap<&str, i32> = hash_map!();
assert!(empty.is_empty());
}
Notice that => inside hash_map! { "Alice" => 95 } is not standard Rust expression syntax—we invented a clean key-value syntax specifically for this macro!
26.5.5 Eliminating Repetitive Trait Implementations
In systems programming, you often need to implement a trait across multiple primitive numeric types. While generics work well when types already share a common trait, primitive types in Rust’s standard library do not share a single unified Number trait. Declarative macros solve this cleanly without code duplication:
trait ZeroAndOne {
const ZERO: Self;
const ONE: Self;
fn is_zero(&self) -> bool;
}
// A macro that implements `ZeroAndOne` for any list of numeric types
macro_rules! impl_zero_and_one {
( $( $t:ty ),+ $(,)? ) => {
$(
impl ZeroAndOne for $t {
const ZERO: Self = 0 as $t;
const ONE: Self = 1 as $t;
fn is_zero(&self) -> bool {
*self == Self::ZERO
}
}
)+
};
}
// Generate implementations for 10 primitive types in one line:
impl_zero_and_one!(u8, u16, u32, u64, i8, i16, i32, i64, f32, f64);
fn main() {
let x: i32 = i32::ZERO;
let y: f64 = f64::ONE;
assert!(x.is_zero());
assert!(!y.is_zero());
}
26.5.6 Hygiene and Identifier Capture
As discussed in Section 26.2.4, Rust’s macro_rules! are hygienic. Local variables declared inside a macro do not clash with variables of the same name outside the macro:
macro_rules! add_with_temp {
($x:expr) => {{
let temp = 100; // `temp` belongs to the macro's hygiene context
$x + temp
}};
}
fn main() {
let temp = 5; // `temp` belongs to `main`'s hygiene context
// In C, this would shadow `temp` and compute 100 + 100 = 200!
// In Rust, `$x` resolves to `main`'s `temp` (5), while the macro's
// internal `temp` is 100, yielding 105.
let result = add_with_temp!(temp);
assert_eq!(result, 105);
}
What if you intentionally want a macro to create a variable, function, or struct with a name visible in the caller’s scope? Because of hygiene, a macro cannot simply write let my_var = 42; and expect the caller to access my_var afterward. Instead, the caller must pass the identifier (:ident) into the macro, so that the identifier carries the caller’s syntax context:
macro_rules! make_getter {
($func_name:ident, $field:ident, $ret_type:ty) => {
pub fn $func_name(&self) -> $ret_type {
self.$field
}
};
}
struct Point {
x: f64,
y: f64,
}
impl Point {
// Passes the identifiers `get_x` and `x` from the caller's context
make_getter!(get_x, x, f64);
make_getter!(get_y, y, f64);
}
fn main() {
let p = Point { x: 3.0, y: 4.0 };
assert_eq!(p.get_x(), 3.0);
assert_eq!(p.get_y(), 4.0);
}
26.5.7 Internal Rules (@ Prefix) and Recursive Macros
Sometimes a macro needs helper arms that are only meant to be called recursively by the macro itself. By convention, Rust macro authors prefix internal helper arms with @ (such as @count or @step) so they cannot be triggered accidentally by normal expressions.
For example, consider improving our hash_map! macro from Section 26.5.4 so that it pre-allocates the exact capacity using HashMap::with_capacity(n), avoiding repeated heap reallocations. We can count the number of pairs at compile time using an internal @count rule:
use std::collections::HashMap;
macro_rules! counted_hash_map {
// Internal helper rule: substitutes each key token tree with `1`
// and sums them inside an array length / constant expression.
(@count $( $key:tt )*) => {
0usize $( + { let _ = stringify!($key); 1usize } )*
};
// Public entry point:
( $( $key:expr => $val:expr ),* $(,)? ) => {{
const CAP: usize = counted_hash_map!(@count $( $key )*);
let mut map = HashMap::with_capacity(CAP);
$(
map.insert($key, $val);
)*
map
}};
}
fn main() {
let ports = counted_hash_map! {
"http" => 80u16,
"https" => 443u16,
"ssh" => 22u16,
};
assert!(ports.capacity() >= 3);
assert_eq!(ports["ssh"], 22);
}
Recursion Limits: When
macro_rules!invokes itself recursively (a pattern known as a TT muncher), the compiler enforces a default recursion depth of 128 steps to catch infinite macro loops. If a legitimate macro hits this ceiling, a crate can raise it with#![recursion_limit = "256"].
26.5.8 Scoping, Exporting, and $crate
Historically, macro_rules! used purely textual (lexical) scoping within a crate: a macro was only visible after its definition line in source order, and submodules could only see macros defined in parent modules if the parent module was annotated with #[macro_use].
In modern Rust, declarative macros can also be imported and re-exported like ordinary items (pub(crate) use my_macro;). When exporting a macro_rules! macro from a library crate so external crates can use it, two tools are essential:
#[macro_export]: Annotating a macro with#[macro_export]places it at the root of the crate and makes it public, so external users can import it withuse my_crate::my_macro;.$crate: When a macro expands inside another crate, relative paths to helper functions or types in your library might not be in scope at the caller’s call site. The special metavariable$cratealways expands to the root path of the crate where the macro was defined:
// Inside your library crate `my_logger`:
pub fn log_internal(msg: &str) {
println!("[LOG] {msg}");
}
#[macro_export]
macro_rules! log_info {
($($arg:tt)*) => {
// Using `$crate::` ensures `log_internal` is found even if the caller
// only imported `log_info!` and not `log_internal`, and using
// `::std::format!` works even if the caller shadowed `format!`.
$crate::log_internal(&::std::format!($($arg)*))
};
}
26.6 Procedural Macros
While declarative macros (macro_rules!) are convenient and self-contained, they can only pattern-match and rearrange tokens; they cannot easily parse arbitrary type definitions, perform complex computations at compile time, or inspect the fields of a struct to generate custom trait implementations.
For those tasks, Rust provides procedural macros (proc macros). A procedural macro is an actual Rust function compiled into a special plugin crate (proc-macro = true) that the compiler loads and executes at compile time. It receives a stream of syntax tokens (proc_macro::TokenStream) as input, runs arbitrary Rust code to analyze or transform those tokens, and returns a new TokenStream as output.
26.6.1 The Three Kinds of Procedural Macros
Rust supports three categories of procedural macros:
- Derive Macros (
#[derive(MyTrait)]): Attach tostruct,enum, oruniondefinitions. They receive the token stream of the type definition and return additional code (typicallyimpl MyTrait for Type { ... }) that is appended after the type definition without modifying the original type itself. Derive macros can also declare helper attributes (such as Serde’s#[serde(rename = "...")]) to customize code generation on individual fields or variants. - Attribute-like Macros (
#[my_attribute]): Can attach to almost any item (functions, structs, traits,implblocks). They receive two token streams—the arguments inside the attribute and the entire annotated item—and replace the item with the returnedTokenStream. Common examples include#[tokio::main]in async Rust or#[get("/users")]in web frameworks. - Function-like Procedural Macros (
my_macro!(...)): Invoked with!just likemacro_rules!, but implemented as a procedural Rust function taking aTokenStreamand returning aTokenStream. Used when token transformations require custom parsing logic beyond whatmacro_rules!patterns can express (such as compile-time SQL validation).
26.6.2 Local Project Setup and the syn / quote Ecosystem
Because procedural macros are compiled into shared host libraries and executed by rustc while compiling another crate, Rust requires procedural macros to live in their own dedicated library crate with proc-macro = true.
However, you do not need to publish anything to crates.io to test procedural macros. You can test all the examples in this section locally by creating a binary crate (macro_demo) and a nested procedural macro library crate (my_macros) linked via a local path dependency:
cargo new macro_demo
cd macro_demo
cargo new --lib my_macros
This produces the following directory layout:
macro_demo/
├── Cargo.toml # Binary crate manifest
├── src/
│ └── main.rs # Application code that invokes the macros
└── my_macros/
├── Cargo.toml # Procedural macro crate manifest
└── src/
└── lib.rs # Procedural macro implementations
In the outer application manifest (macro_demo/Cargo.toml), add a local path dependency pointing to my_macros:
# In macro_demo/Cargo.toml
[package]
name = "macro_demo"
version = "0.1.0"
edition = "2024"
[dependencies]
my_macros = { path = "my_macros" }
In the inner macro crate manifest (my_macros/Cargo.toml), enable proc-macro = true and add the community-standard helper crates syn and quote:
# In macro_demo/my_macros/Cargo.toml
[package]
name = "my_macros"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
syn = { version = "2.0", features = ["full"] }
quote = "1.0"
Why are syn and quote almost universally used? Rust’s built-in proc_macro::TokenStream provides only a low-level sequence of tokens (identifiers, punctuation, literals, and delimited groups)—it does not give you a parsed Abstract Syntax Tree (AST) with struct fields, generics, or function signatures.
synparses a rawTokenStreaminto rich, strongly typed Rust AST data structures (such assyn::DeriveInputfor structs/enums orsyn::ItemFnfor functions).quoteprovides thequote! { ... }macro, which lets you write normal-looking Rust code with#variableinterpolation and converts it back into aTokenStream.
26.6.3 Example 1: A Custom #[derive(...)] Macro
Suppose we want a trait Describe that returns the name of a struct along with the names of its fields. Instead of implementing Describe manually for every struct, we can define a derive macro #[derive(Describe)] inside my_macros/src/lib.rs:
// In macro_demo/my_macros/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Fields, parse_macro_input};
#[proc_macro_derive(Describe)]
pub fn derive_describe(input: TokenStream) -> TokenStream {
// 1. Parse the incoming TokenStream into a syntax tree for a struct/enum
let ast = parse_macro_input!(input as DeriveInput);
let type_name = &ast.ident;
// 2. Extract field names if this is a struct with named fields
let field_names: Vec<String> = match &ast.data {
Data::Struct(data_struct) => match &data_struct.fields {
Fields::Named(fields) => fields
.named
.iter()
.filter_map(|f| f.ident.as_ref().map(|id| id.to_string()))
.collect(),
_ => Vec::new(),
},
_ => {
return syn::Error::new_spanned(
&ast.ident,
"Describe can only be derived for structs",
)
.to_compile_error()
.into();
}
};
let fields_summary = field_names.join(", ");
// 3. Generate the `impl Describe for Type` block using `quote!`
let expanded = quote! {
impl Describe for #type_name {
fn describe() -> String {
::std::format!(
"struct {} with fields: [{}]",
::std::stringify!(#type_name),
#fields_summary
)
}
}
};
// 4. Convert back into the compiler's TokenStream
TokenStream::from(expanded)
}
Notice how we handled invalid input (such as applying #[derive(Describe)] to an enum): instead of calling panic!, we used syn::Error::new_spanned(...).to_compile_error(). This emits a compile_error! attached to the exact source span of the offending identifier, giving the user a clean compiler diagnostic with an underline pointing directly at their type name rather than a generic macro panic backtrace.
Now, in macro_demo/src/main.rs, we define the Describe trait, derive it on our struct, and run cargo run from the macro_demo directory:
// In macro_demo/src/main.rs
use my_macros::Describe;
pub trait Describe {
fn describe() -> String;
}
#[derive(Describe)]
#[allow(dead_code)]
struct SensorReading {
sensor_id: u32,
temperature: f64,
valid: bool,
}
fn main() {
// Outputs: "struct SensorReading with fields: [sensor_id, temperature, valid]"
println!("{}", SensorReading::describe());
}
26.6.4 Example 2: An Attribute-like Macro (#[trace_call])
Attribute macros can wrap or transform an entire function. For example, here is an attribute macro #[trace_call] that logs whenever a function is entered and exited:
// In my_macros/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{ItemFn, parse_macro_input};
#[proc_macro_attribute]
pub fn trace_call(_attr: TokenStream, item: TokenStream) -> TokenStream {
// Parse the annotated function
let input_fn = parse_macro_input!(item as ItemFn);
let attrs = &input_fn.attrs;
let vis = &input_fn.vis;
let sig = &input_fn.sig;
let fn_name = &sig.ident;
let block = &input_fn.block;
// Reconstruct the function, preserving attributes (`#(#attrs)*`)
// and wrapping its original body (`#block`)
let expanded = quote! {
#(#attrs)*
#vis #sig {
::std::println!("--> Entering {}()", ::std::stringify!(#fn_name));
let __result = (|| #block)();
::std::println!("<-- Exiting {}()", ::std::stringify!(#fn_name));
__result
}
};
TokenStream::from(expanded)
}
Three implementation details in the quote! block above are worth noting:
- Preserving outer attributes (
#(#attrs)*): AnItemFnmay carry other attributes (such as#[inline],#[must_use], or doc comments). Re-emitting#(#attrs)*ensures our attribute macro does not silently strip them away. - Why
__resultstarts with double underscores: Unlikemacro_rules!, procedural macros usingquote!are not automatically hygienic on stable Rust—by default, identifiers generated insidequote!share the caller’s syntax context (Span::call_site()). Procedural macro authors therefore conventionally prefix synthesized temporary variables with double underscores (such as__result) to avoid accidentally shadowing or colliding with user-defined identifiers (for example, if the annotated function had a parameter namedresult). Meanwhile,_attrin the function signature uses a single leading underscore for the standard Rust reason: this macro does not take any attribute arguments, so the parameter is intentionally unused. - Why
#blockis wrapped in(|| #block)(): Executing the original function body inside an immediately invoked closure ensures that even if the synchronous function body contains an earlyreturnstatement, it returns only from the inner closure—guaranteeing that the"<-- Exiting ..."log line always runs before the function returns__result.
Applying #[trace_call] to any synchronous function automatically instruments it at compile time:
use my_macros::trace_call;
#[trace_call]
fn compute_checksum(data: &[u8]) -> u32 {
data.iter().map(|&b| b as u32).sum()
}
26.6.5 Example 3: A Function-like Procedural Macro
Function-like procedural macros are annotated with #[proc_macro] and invoked with !, just like macro_rules!. They are useful when you want to validate or transform input at compile time using arbitrary Rust logic—for instance, validating a compile-time configuration string or SQL query, or generating lookup tables:
// In my_macros/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{LitStr, parse_macro_input};
#[proc_macro]
pub fn validate_hex_color(input: TokenStream) -> TokenStream {
let lit = parse_macro_input!(input as LitStr);
let value = lit.value();
// Validate the hex color string at compile time!
let hex = value.strip_prefix('#').unwrap_or_else(|| {
panic!("Hex color must start with '#', got: {value}");
});
if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
panic!("Invalid 6-digit hex color: {value}");
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap();
let g = u8::from_str_radix(&hex[2..4], 16).unwrap();
let b = u8::from_str_radix(&hex[4..6], 16).unwrap();
// Expand to a compile-time RGB tuple `(u8, u8, u8)`
let expanded = quote! {
(#r, #g, #b)
};
TokenStream::from(expanded)
}
When invoked in application code, an invalid color string is caught immediately by the compiler, while a valid string compiles directly into a constant tuple (255u8, 128u8, 0u8) with zero runtime parsing cost:
use my_macros::validate_hex_color;
fn main() {
let orange: (u8, u8, u8) = validate_hex_color!("#FF8000");
assert_eq!(orange, (255, 128, 0));
// let bad = validate_hex_color!("not-a-color"); // Compile-time error!
}
26.7 Choosing the Right Tool: Decision Guide
Because Rust offers several ways to perform compile-time computation and abstraction, use the following table to choose the simplest tool that fits your problem:
| Mechanism | Operates On | Best Used For | Key Limitation |
|---|---|---|---|
| Functions & Generics | Typed values & trait bounds | 95% of everyday code reuse and polymorphism | Fixed arity; cannot generate new types, traits, or syntax |
**const fn & const { ... }** | Typed values at compile time | Compile-time math, lookup tables, parsing constants into arrays/structs | Cannot generate new AST items, traits, or variadic APIs |
Declarative Macros (macro_rules!) | Syntax fragments & token trees | Variadic functions, repetitive impl blocks across types, small inline DSLs | Cannot inspect definitions of existing types; pattern syntax can get complex |
| Procedural Macros | Raw TokenStream via Rust code | Custom #[derive(...)], item-transforming attributes (#[tokio::main]), rich DSLs | Requires a separate proc-macro crate; increases compile times; unhygienic by default |
26.8 Debugging and Inspecting Macros (cargo expand)
When writing or debugging macros, it can be difficult to visualize the exact code produced after macro expansion. The most valuable tool in the Rust ecosystem for inspecting macro output is cargo-expand, a Cargo subcommand created by David Tolnay (the author of syn, quote, and serde).
You can install it via Cargo (note that expanding macros requires the nightly toolchain installed via rustup, though your project itself can remain on stable Rust):
cargo install cargo-expand
Running cargo expand in your project directory prints the complete Rust source code after all declarative macros, #[derive(...)] attributes, and procedural macros have been expanded and formatted with rustfmt:
# Expand the entire crate
cargo expand
# Expand only a specific module or function
cargo expand my_module::my_function
Inspecting the output of cargo expand is also an excellent way to demystify what standard attributes like #[derive(Debug, Clone)] or #[test] actually generate under the hood. In addition, modern IDEs powered by rust-analyzer offer an “Expand macro recursively at caret” command right inside the editor, allowing you to inspect any macro invocation interactively without leaving your workflow.
26.9 Summary
Macros are a cornerstone of Rust’s expressiveness, bridging the gap between high-level ergonomics and low-level zero-cost performance.
Key points to remember:
- Compile-Time Code Generation: Rust macros operate on syntax tokens before type checking, producing normal Rust code with zero runtime overhead.
- Capabilities Beyond Functions: Macros enable variadic arguments (
println!,vec!), compile-time format validation, boilerplate elimination across types, automatic trait derivation (#[derive(...)]), and embedded DSLs. - Safety Over C’s
#define: Unlike C’s text-substitution preprocessor, Rust macros operate on structured token trees and AST nodes. They enforce macro hygiene (preventing accidental variable capture inmacro_rules!), respect operator precedence, obey module visibility rules, and undergo full type and borrow checking after expansion. - Know the Trade-offs: Compared to functions,
const fn, and generics, macros can increase compile times (especially procedural macros usingsynandquote), reduce IDE autocompletion quality, complicate error messages, and make code harder to read. Prefer functions, traits, and generics whenever they suffice. - Declarative Macros (
macro_rules!): Use pattern matching over fragment specifiers (:expr,:ident,:ty,:tt, etc.) and repetition operators ($(...)*,$(...)+,$(...)?) to generate code directly within any crate. - Procedural Macros: Compiled as separate
proc-macro = truecrates, they execute arbitrary Rust functions onTokenStreams to power custom#[derive(...)]macros, attribute macros (#[attr]), and function-like procedural macros. - Tooling: Use
cargo expandorrust-analyzer’s recursive macro expansion command to inspect the code generated by macros when debugging or learning how existing macros work.
26.9.1 Further Reading
- The Little Book of Rust Macros: The definitive community guide to mastering declarative (
macro_rules!) and procedural macros, including advanced patterns like TT munchers and push-down accumulation. - The Rust Reference – Macros: Formal specification of
macro_rules!, hygiene, fragment specifiers, follow-set rules, and procedural macros. synandquoteCrate Documentation: Official documentation and examples for parsing and generating Rust syntax trees in procedural macros.