Rust Macros 2.0 (also called declarative macros) are the macro system the Rust project actively endorses. They generate type-safe code during compilation so repetitive patterns snap together like building blocks. If you want APIs as smooth as serde or tokio, this metaprogramming toolkit is mandatory.
Tired of being told “macros are hard” without learning anything useful? This guide explores the concept, analogies, hands-on examples, and common pitfalls—plus debugging helpers like cargo expand—so you can assemble your own macro toolbox step by step.
Three opening questions: what problems do Rust macros actually solve?
Ever notice yourself re-writing the same shape over and over?
struct User {
id: i32,
name: String,
}
struct Product {
id: i32,
name: String,
price: f64,
}
// Re-implementing Debug every single time...
The little voice whispers, “If only a machine could spit out this boilerplate for me.” Good news: Rust macros are that machine.
But hold on—you might have heard horror stories about C macros: #define monsters that wreck debugging sessions. Rust macros are different. They are syntax transformers, not text copy-pasters. They understand grammar, types, and scope like a sharp teammate.
Still staring at the docs thinking macros are mystical? Relax. We’ll tackle them layer by layer.
Quick analogy: macros are Lego bricks for code
Imagine you’re building a castle with Lego:
- Pre-build standard modules (walls, gates, towers)
- Reuse those modules wherever you need them
- Combine the modules to create unique layouts
Rust macros are the Lego kit for your code:
- Macro modules: pre-fabricated code templates
- Matching rules: how the bricks snap together
- Reusable combos: tiny invocations that yield hefty code
println! is the prime example. One line expands into format-string validation, parameter checks, and console output. That’s the “print brick” finishing an entire subsystem.
Macro crash course: how expansion works
Macros are compile-time code generators. Their expansion goes through five key stages:
1. Recognition
The compiler sees a macro invocation and knows it’s not an ordinary function—just like airport security spotting carry-on luggage.
2. Parsing
Macro inputs are parsed into syntax trees, the equivalent of translating the Lego manual into blueprints.
3. Expansion
Matching rules transform the captured input into new code blocks. Now you’re assembling the real structure.
4. Injection
The generated code is injected into the program’s AST (abstract syntax tree).
5. Keep compiling
From there, the compiler type-checks, optimizes, and codegens as if you had written the code by hand.
The key idea: macros run before runtime. They add compile-time work, not runtime overhead.
Macro 1.0 vs Macro 2.0: spot the difference
| Topic | Macro 1.0 (macro_rules!) | Macro 2.0 (modular declarative macros) |
|---|---|---|
| Import style | Needs #[macro_use] or use crate::macros::* | Normal paths like crate::macros::make_struct! |
| Visibility | Mostly global, collisions everywhere | Honors module visibility with pub, pub(crate) |
| IDE support | Limited branch hints | rust-analyzer offers completion, jumps, docs |
| Maintenance | Huge macro files are brittle | Split into modules, import on demand |
Want the full backstory? Read the macro naming RFC and the macro reference. They explain why modular macros were worth the upheaval.
Macros 2.0 deliver three major upgrades
- Unified namespaces & visibility: macros obey the same module visibility rules as functions. Put them into
mod macros, export withpub(crate)orpub macro(nightly), and import viause crate::macros::make_struct;. The#[macro_use]shadow namespace is history. - Shared TokenStream pipeline: whether it’s
macro_rules!,derive, orproc_macro, everything flows through TokenStream → AST → HIR → MIR → LLVM. That’s whyrust-analyzercan show expansions and Clippy can lint expanded code. - Upgraded macro hygiene: every token remembers where it came from. The compiler keeps call-site and macro-scope IDs, so generated identifiers won’t capture or overwrite outer bindings. You can safely emit locals, control flow, even DSLs without polluting caller code.
Next we’ll warm up with macro 1.0, then migrate to 2.0 and look at how declarative and procedural macros collaborate.
From macro 1.0 warm-up to macro 2.0 hands-on
Macro 2.0 didn’t drop from the sky. It’s macro 1.0 plus modularity, hygiene, and tooling. Walk through these four steps to refresh and upgrade:
Step 0: set up the playground
rustup update
rustc --version # Rust 1.70+ gives you better macro diagnostics
cargo new macro2-playground
cd macro2-playground
Step 1: warm up with macro 1.0 (macro_rules!)
Write a tiny matcher in src/main.rs and feel the “match + expand” rhythm:
macro_rules! add_two {
($a:expr, $b:expr) => {
$a + $b
};
}
fn main() {
let result = add_two!(5, 3);
println!("5 + 3 = {}", result);
}
Observation: macro invocations use
!, patterns use$a:expr-style matchers, and the body is plain Rust.
Step 2: move to modular exports (stable)
Macro 2.0’s headline feature is “macros import like functions.” Put the macro into its own module and re-export it:
// src/macros.rs
macro_rules! add_two {
($a:expr, $b:expr) => {
$a + $b
};
}
pub(crate) use add_two; // Macro 2.0: expose macros via `use`
// src/lib.rs
pub mod macros;
// src/main.rs
use macro2_playground::macros::add_two; // Import like any item
fn main() {
println!("9 + 23 = {}", add_two!(9, 23));
}
Since Rust 1.54, use can bring macros into scope. Visibility now follows the module system instead of a global #[macro_use] switch.
Step 3: try the macro keyword (nightly preview)
Want to leave macro_rules!’ global semantics behind? On nightly, enable decl_macro and reach for the new syntax:
#![feature(decl_macro)]
pub mod macros {
pub macro assert_same_type($a:expr, $b:expr) {
const _: fn() = || {
fn needs_same_type<T>(_: &T, _: &T) {}
needs_same_type(&$a, &$b);
};
}
}
use macros::assert_same_type;
fn main() {
let x = 42u32;
let y = 10u32;
assert_same_type!(x, y); // Passes
}
pub macro obeys normal visibility and automatically plugs into the shared TokenStream pipeline. IDEs can jump straight to definitions.
Step 4: build a macro 2.0-style struct DSL
Now let’s rebuild the make_struct macro with the modular export pattern, generating a struct plus a constructor:
// src/macros.rs
macro_rules! make_struct {
($name:ident { $($field:ident: $ty:ty),* $(,)? }) => {
#[derive(Debug, Clone)]
pub struct $name {
$(pub $field: $ty),*
}
impl $name {
pub fn new($($field: $ty),*) -> Self {
Self { $($field),* }
}
}
};
}
pub(crate) use make_struct;
// src/main.rs
use macro2_playground::macros::make_struct;
make_struct!(
Person {
name: String,
age: u32,
email: String,
}
);
fn main() {
let person = Person::new(
"Alice".into(),
29,
"alice@rust.dev".into(),
);
println!("{:?}", person);
}
Now the IDE knows exactly where make_struct! lives, rust-analyzer can jump to it, and tweaking macros.rs updates every call site—precisely the maintainable workflow macro 2.0 aims for.
Macro 2.0 module model: side-by-side comparison
Put the old and new approaches together and the contrast becomes obvious.
Legacy style: #[macro_use] global injection
// lib.rs
#[macro_use]
mod macros;
fn main() {
println!("{}", add_two!(1, 2)); // IDE has no idea where add_two came from
}
Problems:
- Macros leak across the entire crate, so naming conflicts run rampant.
- IDEs struggle to locate definitions.
- You end up relying on tribal knowledge to maintain macro collections.
Modular export (stable)
// macros.rs
macro_rules! add_two {
($a:expr, $b:expr) => {
$a + $b
};
}
pub(crate) use add_two; // Since Rust 1.54
// lib.rs
pub mod macros;
// main.rs
use crate::macros::add_two;
fn main() {
println!("{}", add_two!(1, 2));
}
Benefits:
- Macro imports mirror functions and types.
- Split macro libraries by module, control visibility with
pubandpub(crate). rust-analyzerjumps straight to definitions and errors read better.
Preview style: pub macro (nightly)
#![feature(decl_macro)]
pub mod macros {
pub macro double($x:expr) {
2 * $x
}
}
use macros::double;
fn main() {
println!("{}", double!(21));
}
pub macro fully embeds macros into the module system. Once stabilized, this will be the default macro 2.0 syntax. Until then, keep using the stable combo of macro_rules! + pub use.
TokenStream pipeline: how macro 2.0 cooperates with the compiler
Rust’s three macro families—macro_rules!, pub macro, and proc_macro—now ride the same pipeline:
- Token collection: the call site is parsed into a
TokenStreamwith token + span data. - Macro matching / execution: declarative macros match patterns and emit new
TokenStreams; procedural macros run Rust code that returnsTokenStreams. - Re-parsing: the compiler treats the generated stream as user code, parsing it into AST → HIR → MIR.
- Incremental compilation & caching: macro 2.0 records dependencies so expansions rerun only when the macro source or input changes.
- IDE & tooling cooperation:
rust-analyzer, Clippy,cargo fmt, and friends consume the same pipeline, so they can inspect expanded code.
Curious about the expansion? cargo expand is the quickest window into reality:
cargo install cargo-expand
cargo expand make_struct
The output is the exact Rust the compiler sees. Perfect for debugging matching logic or deciphering an error.
Macro hygiene: how Rust avoids accidental capture
Macro 2.0 takes hygiene seriously. Every token carries a scope ID so the compiler can tell whether identifiers belong to the macro or the caller:
macro_rules! shadow_play {
() => {{
let x = 10; // macro-local x
x
}};
}
fn main() {
let x = 5; // call-site x
let y = shadow_play!();
println!("x = {}, y = {}", x, y);
}
The output is x = 5, y = 10. The macro’s x never tramples the outer x. If you need outside data, pass it explicitly or use helper macros like concat_idents!. Hygiene keeps compile-time generation predictable.
Declarative & procedural macros in harmony
Macro 2.0 unifies declarative and procedural macros under the same namespace so they can tag-team advanced DSLs. Here’s a simplified “compile-time SQL guard” example:
// dsl/src/lib.rs
use proc_macro::TokenStream;
use syn::{spanned::Spanned, LitStr};
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
let lit = syn::parse_macro_input!(input as LitStr);
let query = lit.value();
if !query.trim_start().to_uppercase().starts_with("SELECT") {
return syn::Error::new(lit.span(), "only SELECT queries are allowed")
.to_compile_error()
.into();
}
quote::quote!(#lit).into()
}
Dependencies: in
dsl/Cargo.tomladdsyn = { version = "2", features = ["full"] }andquote = "1". Mark the crate asproc-macro = true.
// app/src/macros.rs
macro_rules! select_user_by_id {
($id:expr) => {{
const QUERY: &str = sql!("SELECT * FROM users WHERE id = ?");
(QUERY, $id)
}};
}
pub(crate) use select_user_by_id;
// app/src/main.rs
use app::macros::select_user_by_id;
fn main() {
let (query, id) = select_user_by_id!(42);
// Compile-time validation: only SELECT statements pass
println!("SQL: {} with id {}", query, id);
}
Declarative macros provide the sugary syntax, procedural macros enforce compile-time guards. Thanks to macro 2.0’s unified namespace they reference each other like ordinary functions, delivering DSLs that are both ergonomic and safe.
Macros vs functions: when to reach for which?
Functions generate machine code and incur call overhead:
fn add(a: i32, b: i32) -> i32 {
a + b
}
Macros expand before compilation, removing call overhead but inflating code size:
macro_rules! add {
($a:expr, $b:expr) => {
$a + $b
};
}
Macro 2.0 decision guide:
- Functions: pure logic, reusable behavior, easy unit tests.
- Declarative macros: eliminate repetitive syntax templates, design ergonomic DSLs.
- Procedural macros: parse or generate arbitrary syntax trees, perform compile-time validation.
- Inline functions: if all you want is fewer call overheads, you don’t necessarily need macros.
Common pitfalls newcomers hit
Pitfall 1: repeated evaluation
macro_rules! bad_example {
($x:expr) => {
println!("{}", $x);
println!("{}", $x); // $x is evaluated twice!
};
}
fn main() {
let mut i = 0;
bad_example!(i += 1); // Prints twice, i becomes 2
}
Fix: capture the expression once.
macro_rules! good_example {
($x:expr) => {
let value = $x;
println!("{}", value);
println!("{}", value);
};
}
Pitfall 2: confusing scope & hygiene
macro_rules! make_var {
() => {
let x = 10;
};
}
fn main() {
let x = 5;
make_var!();
println!("{}", x); // Prints 5, not 10—macro x is local
}
Rust binds the macro-generated x to a hygienic scope, so it never pollutes the caller. To pass a value out, return it explicitly:
macro_rules! make_var {
() => {{
let x = 10;
x
}};
}
fn main() {
let x = make_var!();
println!("{}", x); // 10
}
Pitfall 3: type-system blind spots
Macros don’t perform type checking—they just emit code:
macro_rules! multiply {
($a:expr, $b:expr) => {
$a * $b
};
}
fn main() {
let s = "hello";
let n = 5;
multiply!(s, n); // The type error shows up after expansion
}
Solution: rely on the expanded code’s type checks, or add compile-time guards such as assert_same_type! so mismatches fail earlier with clearer messages.
Performance and trade-offs
Advantages
- Zero runtime overhead: expansions become plain Rust
- Less duplication: DRY, enforced at compile time
- Expressive syntax: craft DSLs and fluent APIs
Disadvantages
- Longer compile times: expansion + recompilation takes work
- Debugging friction: error messages point to expanded code
- Learning curve: you must reason about syntax trees
Real-world test: projects with and without macros run at similar speeds—the difference lives in compile time.
Where macros shine in real projects
Scenario 1: serialization / deserialization (Serde)
#[derive(Serialize, Deserialize)]
struct Config {
host: String,
port: u16,
}
Scenario 2: HTTP routing (Axum)
routes![get("/users", list_users)];
Scenario 3: async traits (async_trait)
#[async_trait]
trait Database {
async fn fetch(&self, id: u32) -> User;
}
Scenario 4: testing frameworks (mocks)
mock!(Database::fetch)
.times(1)
.returning(|id| User { id });
Rust macro 2.0 FAQ
Can I use macro 2.0 features on stable Rust?
“Macro 2.0” is the community nickname for the modular declarative macro improvements. Most of it—module definitions, path imports—is already stable. You can write macro_rules! today with macro 2.0 ergonomics. Newer syntax like pub macro still needs nightly with #![feature(decl_macro)], so keep an eye on the release notes.
Macro 2.0 vs procedural macros: how do I choose?
Procedural macros transform full syntax trees (think derive, attribute macros) and can parse arbitrary structures. Declarative macros focus on pattern matching and generating predictable templates. The litmus test: do you need custom syntax parsing? If yes, procedural macros. Otherwise declarative macros are easier to maintain.
What tools help debug macros?
cargo expandto inspect expansions and catch accidental repeated evaluation- The IDE macro expansion view (
rust-analyzer) for quick navigation - Nightly’s
RUSTFLAGS="-Z macro-backtrace"for complete macro call stacks when bugs get gnarly
Summary and next steps
Three key takeaways
- Macro 2.0 slots macros into the module system and TokenStream pipeline, making them feel like normal functions.
- Upgrade your
macro_rules!withpub use,pub macro, and procedural partners to build ergonomic DSLs. - Pair hygiene,
cargo expand, and compile-time assertions to keep macro-generated code verifiable and maintainable.
Action checklist
- Get hands-on: refactor repetitive code in your project with a macro.
- Read the source: see how Serde, Tokio, and friends structure their macro libraries.
- Level up systematically: browse the Rust tutorial hub for your next learning path.
- Go deeper: learn
syn,quote, andproc_macroto build compile-time safeguards. - Master the toolbelt: combine
cargo expand,rust-analyzer, and-Z macro-backtracefor macro debugging.
Resources
- The Rust Programming Language: Macros
- The Little Book of Rust Macros for a deep dive
cargo expand—the go-to tool for inspecting macro expansions
Remember: macros are tools, not status symbols. Use them when they save real effort, not to show off. Start small, keep refining, and soon you’ll run your own Lego kingdom of reusable Rust patterns.
