← Rust: Zero to Depth
Full-stack DevRust: Zero to Depth

Macros: Code That Writes Code, and When to Resist Writing It

Part 12 of the Rust: Zero to Depth series: why println! can only be a macro, the pattern-matcher nature of macro_rules! declarative macros, how hygiene quarantines macro-local variables, the $crate path convention, the three procedural-macro brothers (derive / attribute / function-like) with a hand-built mini derive, and the judgment ladder for when not to use a macro.

RustMacrosmacro_rulesProcedural MacrosMetaprogramming

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 functionsTokenStream 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 your async fn main into a plain fn main that builds a runtime and block_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:

  1. If a plain function solves it, use a function (the overwhelming majority of cases).
  2. Needs to work across types → generics + trait bounds (Part 7) — full type checking, first-class error messages.
  3. Needs “behavior dispatched by type” → traits (Part 5).
  4. 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

  1. Add a third rule to my_vec!: my_vec![x; n], expanding to the equivalent of vec![$x; $n] (hint: vec! itself is fair game — the standard library is your reference implementation). Then run cargo expand and read all three rules’ output with your own eyes.
  2. Write a hashmap! macro: hashmap!{ "a" => 1, "b" => 2 } returns a filled HashMap. It’s the classic of the declarative-macro exercise book; finish it and the $()* repetition syntax grows into your hands.
  3. Extend the mini derive one step: make hello() also print the number of fields (hint: the data field of DeriveInput). 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.

guest@swangnice:~$