Rust Macros: Declarative vs Procedural
A practical comparison of declarative macro_rules! and procedural macros in Rust, including derive, function-like, and attribute macros with examples.
What you'll learn
- ✓The difference between declarative and procedural macros
- ✓How macro_rules! pattern matching works
- ✓Function-like, derive, and attribute proc macros
- ✓When to write a macro versus a generic function
- ✓Hygiene and the trade-offs of macro-heavy code
Prerequisites
- •Comfort with basic Rust syntax
What and Why
Rust offers two macro systems. Declarative macros (macro_rules!) work by pattern-matching on token trees, expanding to other token trees. Procedural macros are functions that receive a TokenStream and return a new one, executed by the compiler.
Macros let you remove boilerplate that generics cannot eliminate: deriving traits, building DSLs, generating repetitive code. The cost is reduced clarity and slower compile times, so reach for them deliberately.
Mental Model
macro_rules! foo { ... } declarative; pattern -> expansion
#[derive(MyTrait)] custom derive proc macro
#[my_attr(...)] fn ... {} attribute proc macro
my_macro!(...) function-like proc macro
declarative -> stays in the crate, no extra crate needed
procedural -> must live in a proc-macro = true crate Declarative macros are simpler and faster to compile. Procedural macros are more powerful but require a separate crate and the proc-macro2, syn, and quote ecosystem to be ergonomic.
Hands-on Example
A declarative macro that builds a HashMap:
use std::collections::HashMap;
macro_rules! map {
() => { HashMap::new() };
($($k:expr => $v:expr),+ $(,)?) => {{
let mut m = HashMap::new();
$( m.insert($k, $v); )+
m
}};
}
fn main() {
let scores: HashMap<&str, i32> = map!{"ada" => 90, "bob" => 75};
println!("{:?}", scores);
}
The macro accepts an empty call or any number of key => value pairs and expands to a sequence of insert calls inside a block.
A procedural derive macro lives in its own crate with proc-macro = true:
# my-derive/Cargo.toml
[lib]
proc-macro = true
[dependencies]
syn = "2"
quote = "1"
// my-derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[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 {
pub fn hello(&self) {
println!("hello from {}", stringify!(#name));
}
}
}.into()
}
Consumers depend on the crate and write #[derive(Hello)] on their structs. The compiler runs the proc macro at compile time and inlines the generated impl.
Common Pitfalls
- Captured identifiers and hygiene: declarative macros are hygienic by default; identifiers introduced inside the macro don’t clash with the caller’s scope. Procedural macros are not hygienic and must be careful about identifier names.
- Token kinds matter:
$x:exprmatches an expression,$x:identan identifier,$x:tya type. Using the wrong kind produces confusing errors at the call site. - Recursion limits: deeply recursive
macro_rules!hit the default recursion limit. Either restructure or raise it with#![recursion_limit = "256"]. - Slow compile times with proc macros: complex proc macros run on every build. Cache aggressively in CI and measure with
cargo build --timings. - Debugging is hard:
cargo expand(a cargo subcommand) prints the post-macro source, which is the fastest way to understand what your macro actually produces.
Practical Tips
Prefer functions and generics before reaching for a macro. Macros are a last resort when types alone cannot express the pattern.
For declarative macros, name them clearly and document the supported call shapes with examples in the doc comment.
For proc macros, build on syn and quote. syn parses Rust into an AST you can match on; quote interpolates Rust syntax with #var placeholders. The combination is dramatically more pleasant than manipulating raw TokenStreams.
Test macros like any other code: declarative macros in the same crate, proc macros via an integration crate that uses them and checks the resulting behavior.
When errors point inside a macro expansion, run cargo expand to see the generated code. Many “weird” errors become obvious once you can read the expansion.
Wrap-up
Declarative macros give you pattern-driven code generation that stays inside one crate. Procedural macros are full programs that run during compilation and unlock derive, attribute, and function-like syntax. Both should be used sparingly: every macro is a small language your collaborators have to learn. When the boilerplate is real and generics cannot help, macros are the right tool. Reach for macro_rules! first, escalate to proc macros when you need to inspect or generate types and items.
Related articles
- Rust Rust Actix vs Axum Comparison
A side-by-side comparison of Actix Web and Axum, covering architecture, ergonomics, performance, ecosystem, and how to pick the right one for your project.
- Rust Rust async/await and Futures Explained
How Rust's async/await desugars into a state machine, what a Future actually is, and the runtime model that makes it efficient on real workloads.
- Rust Rust Axum Web Framework Tutorial
A practical introduction to building HTTP services in Rust using the Axum web framework, with routing, extractors, state, and JSON handling.
- Rust Rust Builder Pattern Explained
Learn the builder pattern in Rust, why it fits the language so well, and how to use it for ergonomic, type-safe configuration of complex structs.