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

TopicMacro 1.0 (macro_rules!)Macro 2.0 (modular declarative macros)
Import styleNeeds #[macro_use] or use crate::macros::*Normal paths like crate::macros::make_struct!
VisibilityMostly global, collisions everywhereHonors module visibility with pub, pub(crate)
IDE supportLimited branch hintsrust-analyzer offers completion, jumps, docs
MaintenanceHuge macro files are brittleSplit 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

  1. Unified namespaces & visibility: macros obey the same module visibility rules as functions. Put them into mod macros, export with pub(crate) or pub macro (nightly), and import via use crate::macros::make_struct;. The #[macro_use] shadow namespace is history.
  2. Shared TokenStream pipeline: whether it’s macro_rules!, derive, or proc_macro, everything flows through TokenStream → AST → HIR → MIR → LLVM. That’s why rust-analyzer can show expansions and Clippy can lint expanded code.
  3. 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 pub and pub(crate).
  • rust-analyzer jumps 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:

  1. Token collection: the call site is parsed into a TokenStream with token + span data.
  2. Macro matching / execution: declarative macros match patterns and emit new TokenStreams; procedural macros run Rust code that returns TokenStreams.
  3. Re-parsing: the compiler treats the generated stream as user code, parsing it into AST → HIR → MIR.
  4. Incremental compilation & caching: macro 2.0 records dependencies so expansions rerun only when the macro source or input changes.
  5. 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.toml add syn = { version = "2", features = ["full"] } and quote = "1". Mark the crate as proc-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 expand to 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

  1. Macro 2.0 slots macros into the module system and TokenStream pipeline, making them feel like normal functions.
  2. Upgrade your macro_rules! with pub use, pub macro, and procedural partners to build ergonomic DSLs.
  3. Pair hygiene, cargo expand, and compile-time assertions to keep macro-generated code verifiable and maintainable.

Action checklist

  1. Get hands-on: refactor repetitive code in your project with a macro.
  2. Read the source: see how Serde, Tokio, and friends structure their macro libraries.
  3. Level up systematically: browse the Rust tutorial hub for your next learning path.
  4. Go deeper: learn syn, quote, and proc_macro to build compile-time safeguards.
  5. Master the toolbelt: combine cargo expand, rust-analyzer, and -Z macro-backtrace for macro debugging.

Resources

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.