First, answer a question you’ve been using since Part 1 but may never have asked: why isn’t println! a function? Because it takes any number of arguments — println!("{}", x), println!("{} {}", x, y) — and Rust functions have no variadics. Same for vec!. Those exclamation marks you use every day are all macros: code that writes code — taking fragments of your code at compile time, spitting out more code, and the compiler then pretends you wrote it all yourself. This part unpacks the metaprogramming craft: the pattern-matcher nature of declarative macros, hygiene — the lifesaving design everyone forgets — the three procedural-macro brothers and their territories, and the one judgment framework in this series that starts with when not to use the feature.
Expansion happens before type checking
The first fact to understand about macros is where they sit in the compile pipeline: expansion precedes type checking. Macros operate on syntax — sequences of tokens — at a time when types don’t exist yet.
Two everyday experiences follow from this one fact. The good: the borrow checker never sees a macro, only the expanded ordinary code — macros can’t bypass a single safety check; the expansion is the whole truth (and cargo expand lets you eyeball it anytime). The pain: macro errors point into expanded code, which is code you didn’t write — “error in this macro expansion” is a sentence every Rustacean has read, and it’s the first line on the macro’s price list.
Declarative macros: macro_rules! is a pattern matcher
At its core, macro_rules! is a pattern-match-plus-template machine: the left side is a pattern of “what the call looks like,” the right side a template of “what it expands into.” Write a miniature vec! by hand:
macro_rules! my_vec {
() => { Vec::new() };
($($x:expr),+ $(,)?) => {{
let mut v = Vec::new();
$( v.push($x); )+
v
}};
}
let a: Vec<i32> = my_vec![]; // hits rule one
let b = my_vec![1, 2, 3]; // hits rule two
let c = my_vec![1, 2, 3,]; // $(,)? eats the trailing comma
Unpacking the syntax: $x:expr captures an expression under the name x (other capture kinds: ident, ty, stmt, block, …); $( ... ),+ means “comma-separated, at least one” repetition; $(,)? is the “optional trailing comma” — don’t underestimate it: without it your macro refuses to compile after multi-line formatting, and it’s standard equipment on every hand-written macro; in the template, $( ... )+ repeats the expansion per capture. Rules match top to bottom, first hit wins — exactly like match, except the scrutinee is syntax.
Hygiene: macro-local variables are quarantined
C macros are text substitution, and one careless day they eat the caller’s variables. Rust’s declarative macros are hygienic: locals introduced in the expansion are implicitly renamed and can never collide with, or capture, a same-named variable of the caller. Verify by hand:
macro_rules! inc_twice {
($x:expr) => {{
let mut tmp = $x;
tmp += 1;
tmp += 1;
tmp
}};
}
let tmp = 10;
let r = inc_twice!(5);
println!("tmp = {tmp}, r = {r}"); // tmp = 10, r = 7 — the caller's tmp is untouched
The macro’s tmp is quietly renamed to something unguessable; the caller’s tmp lives on in peace. This design lets macros safely create intermediate variables — C programmers may need a moment here.
The exception to hygiene is paths: when the macro body references items from its own crate, write $crate::helper(), not crate::helper() — the latter resolves in the caller’s crate, while $crate always points at the crate that defined the macro. For any macro living in a library, this is standard accessory number two, right after the trailing comma.
The three procedural-macro brothers: TokenStream in, TokenStream out
Declarative macros are pattern matching; procedural macros are real functions — TokenStream in, TokenStream out, compiled as compiler plugins that run at compile time. They can see the full syntactic structure of your code (parsed with syn, generated with quote), so they can do what declarative macros can’t: generate code from the structure of your types. Three brothers, three territories:
- derive: leaves your type untouched and appends generated
impls beneath it. All of serde’s magic, and the#[derive(Debug)]you write daily, lives here. - attribute: rewrites the item it sits on — wrap, replace, instrument. Part 10’s
#[tokio::main]is one: it rewrites yourasync fn maininto a plainfn mainthat builds a runtime andblock_ons. - function-like: looks like a declarative-macro call, but the expansion logic is arbitrary Rust code.
sqlx::query!connects to a database at compile time to validate your SQL — something a declarative macro wouldn’t dare dream of.
Paper is cheap; build one. A miniature derive that actually works, thirty lines:
// hello-derive/src/lib.rs — a proc-macro crate
use proc_macro::TokenStream;
use quote::quote;
use syn::{DeriveInput, parse_macro_input};
#[proc_macro_derive(Hello)]
pub fn derive_hello(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = ast.ident;
quote! {
impl #name {
fn hello() -> String {
format!("hello from {}!", stringify!(#name))
}
}
}
.into()
}
// the consumer
use hello_derive::Hello;
#[derive(Hello)]
struct Report;
fn main() {
println!("{}", Report::hello()); // hello from Report!
}
The structure is transparent: syn parses tokens into a syntax tree (DeriveInput), you pull out the type name, and quote! stitches it back into the generated impl template. Serde’s core loop is isomorphic to this, just ten thousand times bigger — understand these thirty lines and you understand the entire principle of procedural macros.
When not to use a macro: the judgment ladder
Macros are the one feature in this series where you first learn when not to use them, because their costs are real and hidden: errors filtered through an expansion layer, IDE completion going blind, longer compiles, and readers who must run the expander in their heads. The framework is a ladder — use the lowest rung that works:
- If a plain function solves it, use a function (the overwhelming majority of cases).
- Needs to work across types → generics + trait bounds (Part 7) — full type checking, first-class error messages.
- Needs “behavior dispatched by type” → traits (Part 5).
- Only three situations call for a macro: variadic input (the
println!shape), extensions functions can’t express syntactically (lazy evaluation, little languages), and batch-generating impls from type structure (derive’s territory).
The one-sentence version: macros program at the syntax layer, and the type system is your airbag — whatever can be solved at the type layer, never demote to syntax.
Practice, then Part 13
- Add a third rule to
my_vec!:my_vec![x; n], expanding to the equivalent ofvec. Then runcargo expandand read all three rules’ output with your own eyes. - Write a
hashmap!macro:hashmap!{ "a" => 1, "b" => 2 }returns a filledHashMap. It’s the classic of the declarative-macro exercise book; finish it and the$()*repetition syntax grows into your hands. - Extend the mini derive one step: make
hello()also print the number of fields (hint: thedatafield ofDeriveInput). You’ll get your first feel of “generating code from type structure” — the thing serde does all day.
Part 13 is the series’ penultimate stop: performance engineering. The measure-first discipline (cargo bench versus profilers), allocation as the invisible tax, how to verify the zero-cost promise of iterators (reading the assembly yourself), Vec’s growth strategy and pre-allocation, and the correct version of that over-quoted saying — it’s the second half of “premature optimization is the root of all evil” that belongs to engineers.