You now know how to write macros, which means you are at the point of maximum
danger. The predictable failure mode of a macros chapter is a learner who spends
the next month replacing perfectly good functions with macro_rules!
definitions.
This is the decision procedure, stated as a ladder, with the disagreements included rather than smoothed over.
The ladder
Rung 1 — traits and generics. The default. Try this first, always.
Rung 2 — macro_rules!. When you need one of the three things a function
fundamentally cannot do (18.1): a variable number of arguments of differing
types, syntax that is not an expression, or generated items.
Rung 3 — a procedural macro. When you must inspect structure — the fields of a struct, the variants of an enum, the parameters of a function — or accept syntax that is not valid Rust.
Never skip a rung because the one above looks like more typing. Do not stay on a rung when you are clearly fighting it.
Rung 1 covers more than you think
The most common bad macro is one that exists because “I need this for five types”. That is what generics are.
// A macro, because it "has to work for i32, i64, f32, f64, u8"
macro_rules! clamp_all { ... }
// A generic function, because that is what the feature is
fn clamp_all<T: PartialOrd + Copy>(xs: &mut [T], lo: T, hi: T) { ... }
The generic version type-checks once at the definition, gives errors that point at your code, works with go-to-definition, shows inlay hints, is documented by rustdoc, and can be unit tested directly. The macro version does none of that and type-checks separately at every call site.
The signal that you have mistaken rung 1 for rung 2 is that your macro body
contains no unusual syntax — it is just ordinary Rust with $t where a type
should be. If a type parameter would do, use a type parameter.
Blanket impls are the other underused tool here. “The same method for everything
that implements Display“ is impl<T: Display> MyTrait for T, not a macro over
a list of types.
When rung 2 is right
Four honest signals:
-
Variadics.
println!-shaped APIs: a variable number of arguments of unrelated types. There is no generic formulation. -
Non-expression syntax.
matches!(x, Some(n) if n > 3)takes a pattern.vec![0; 1024]takes a shape. Functions cannot receive either. -
Item generation. A table of constants, a set of newtype wrappers, an enum
plus its
all()(18.9). If the output is items, a function cannot produce it. - Boilerplate that a trait genuinely cannot factor out — usually because the repetition is in the shape of the code rather than in its behaviour.
macro_rules! handles the large majority of real boilerplate needs, and it has
two properties that make it much cheaper than rung 3: it is part of your crate,
and it adds no build-time dependency and executes no code during the build.
When rung 3 is right
Two signals, and they are narrow:
-
You must inspect structure. Iterate a struct’s fields, enumerate an enum’s
variants, read a function’s parameters.
macro_rules!cannot do this (18.16, point 4) unless you are willing to declare every affected type through your macro, which is a large commitment. - The input is not Rust. SQL, JSON, HTML, a routing DSL. No fragment specifier describes it, so no declarative macro can accept it.
If neither applies, rung 3 is buying you nothing and costing you plenty.
What rung 3 actually costs
Worth listing, because the costs are diffuse and the benefits are visible:
-
A compile-time dependency on
syn.synwith thefullfeature is a substantial crate. It is compiled once per toolchain per feature set, but on a cold build it is real time, and it lands on everyone who depends on you. - Arbitrary code execution during the build. A proc macro is a program rustc runs on the developer’s machine. It can read files, open sockets, spawn processes. This is not hypothetical paranoia — it is why some organisations audit proc-macro dependencies specifically.
- A separate crate, forever, because a proc macro cannot live in the crate it transforms (18.17).
-
Worse IDE support than
macro_rules!, which was already worse than plain code. Go-to-definition through a derive is unreliable; renaming a field does not update the generated code that mentions it. -
Harder debugging.
cargo expandis essential rather than optional, and the errors point at code nobody wrote.
The live disagreement
Here is where competent people genuinely differ, and it is better to know that than to be handed a rule.
Position A: proc macros outside derives should be banned. Build times are a
first-order engineering concern; syn in the dependency graph is a cost every
contributor pays on every clean build; attribute macros that rewrite whole items
make code unreadable and undebuggable; and the “arbitrary code at build time”
property is a supply-chain hole. Teams that maintain large, long-lived services
often land here.
Position B: #[derive(Serialize)] is non-negotiable. Hand-writing
serialisation for two hundred types is not a real alternative; the ergonomics are
worth the build cost by an enormous margin; #[tokio::main] and #[test] are
part of how the language is actually used, and pretending otherwise is
performance art. Teams shipping application code usually land here.
Both are right about their own context. The synthesis most people converge on:
derives are fine and normal; attribute macros that rewrite items deserve
scrutiny; function-like proc macros are worth it when they buy compile-time
checking of a foreign language (sqlx::query! catching a SQL typo at build
time is a genuinely large win).
The IDE cost, stated plainly
Beginners do not anticipate this and it is the complaint that arrives loudest six months later.
Inside macro expansions, rust-analyzer degrades: inlay type hints go missing,
go-to-definition lands somewhere unhelpful, “find all references” misses call
sites, and rename refactors do not reach generated code. It is not a bug awaiting
a fix — resolving the output requires running the expansion, and the IDE cannot
always do that quickly or at all.
For a macro used ten times in one file, irrelevant. For a codebase where every handler, every model and every error type is defined through a macro, it is a permanent tax on everyone who reads the code, including you in a year.
The heuristic to keep
“Just use a macro” is usually a sign that a trait with a blanket impl was the better answer.
Ask, in order:
- Could a generic function or a trait do this? → do that.
-
Do I need varargs, non-expression syntax, or generated items? →
macro_rules!. - Do I need to see the fields, or accept syntax that is not Rust? → proc macro.
- None of the above? → I have not understood the problem yet.
And when you do write a macro: keep its surface small, document its accepted
syntax with examples, accept a trailing comma, and put #[warn(meta_variable_misuse)]
above it while you work (18.13).