Skip to content

Rust Macros: When macro_rules! Earns Its Place (And When It Doesn’t)

Rust Macros: When macro_rules! Earns Its Place (And When It Doesn’t)

I put off learning Rust macros for about a year. Every time I hit code that obviously wanted a macro, I’d shrug, copy-paste the thing a few more times, and promise myself I’d learn the macro system “properly” later. Later never showed up. What finally broke me was a match block I’d rewritten so many times my git blame looked like it had a stutter.

So I gave up a weekend and actually sat with them. Turns out I’d been scared of the wrong half. macro_rules!, the declarative kind, is far friendlier than its reputation. The genuinely gnarly part, procedural macros, I reach for maybe once a project, if that. This is the guide I wish someone had handed me: what the two kinds are, when each one earns its keep, and the times I’ve regretted writing a macro at all.

Two kinds of macros, and why the split matters

Rust has two macro systems, and beginners (me, a year ago) tend to lump them together and get intimidated by the union of their worst parts.

The first is declarative macros, written with macro_rules!. You pattern-match on syntax and expand into more syntax. Think of it as a very literal find-and-replace that understands Rust’s grammar. Ninety percent of the macros you’ll ever write are this kind, and they live right in your normal source files.

The second is procedural macros: custom derive, attribute macros like #[tokio::main], and function-like macros such as sqlx::query!. These run actual Rust code at compile time to generate code. They’re strictly more powerful and strictly more annoying. They need their own crate, they pull in heavy dependencies, and they slow your builds down. The official macros chapter of the Rust book lays out the taxonomy if you want the formal version.

The practical takeaway: you write macro_rules!, you consume procedural macros other people wrote. Keep those in separate mental buckets and the fear mostly evaporates.

macro_rules!: the one you’ll actually write

Here’s the code that finally sold me. I had a config-building block that looked like this, in a few different places:

let mut config = HashMap::new();
config.insert("host", "localhost");
config.insert("port", "5432");
config.insert("user", "claude");
config.insert("sslmode", "require");

Fine once. Tedious the fourth time. So I wrote a tiny macro:

macro_rules! map {
    ($($key:expr => $val:expr),* $(,)?) => {{
        let mut m = std::collections::HashMap::new();
        $( m.insert($key, $val); )*
        m
    }};
}

And the call site collapses to this:

let config = map! {
    "host" => "localhost",
    "port" => "5432",
    "user" => "claude",
    "sslmode" => "require",
};

Three things are worth unpacking, because they’re most of what you need to read any macro_rules! in the wild:

  • $key:expr captures a Rust expression and binds it to $key. That :expr is a fragment specifier. Common ones are expr, ident, ty, tt, and literal.
  • $( ... ),* is repetition. It says “match this pattern zero or more times, separated by commas,” and the matching $( ... )* in the body expands once per capture.
  • $(,)? at the end permits an optional trailing comma, so your macro doesn’t choke on the comma you always leave after the last line.

That’s the whole trick. You match a shape, you emit a shape. No separate crate, no syn, no build-time gymnastics. When I stopped treating macro_rules! as advanced and started treating it as a slightly weird function that takes syntax, it clicked.

Procedural macros: powerful, and mostly not yours to write

Every Rust project you’ve touched already leans on procedural macros. #[derive(Serialize, Deserialize)] from serde is a derive macro that writes a few hundred lines of serialization code you never see. sqlx::query! checks your SQL against a real database at compile time. That’s a function-like proc macro doing genuine work before your program ever runs. I got into why that compile-time checking changed how I write data access in my post on sqlx vs diesel, and the query! macro is a big part of why.

Writing your own proc macro is a different sport. You need a separate crate marked proc-macro = true, and you parse a TokenStream using the syn crate, then rebuild output with quote. A skeleton derive looks like this:

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 {
            fn hello() { println!("hi from {}", stringify!(#name)); }
        }
    }.into()
}

It’s not scary once you’ve read it, but notice the cost: an extra crate, two heavy dependencies, and a chunk of compile time on every build. I’ve written exactly two proc macros in production, both to remove derive boilerplate across dozens of types where a function couldn’t reach. If your use case doesn’t look like that, you probably don’t need one yet.

When a macro is the wrong tool

This is the section a year-ago me needed most, because the moment you learn macros everything looks like a nail.

Reach for a plain function or a generic first. Almost always. Functions get real type checking, they show up properly in your IDE, and when they break the error points at your actual mistake. Macros give up a lot of that. When a macro_rules! expansion goes wrong, the compiler error often lands inside the macro definition, and you’re left squinting at where your input actually broke the pattern.

My rough rule: I only write a macro when I’m generating syntax a function can’t produce. Building a HashMap with clean literal syntax, implementing the same trait across many types, or capturing an expression unevaluated so I can log it and run it. If the thing you want is “run this code with these values,” that’s a function, and Rust generics probably already cover the “works for many types” part. Macros hurt readability for the next person, and half the time the next person is you in three months with no memory of the clever expansion you wrote.

Debugging macros without losing your weekend

The reason macros feel cursed is that you can’t see what they expand into. Until you can. Install cargo-expand and run:

cargo expand --bin myapp

It prints your code with every macro fully expanded into plain Rust. The first time I ran it on a serde struct I actually understood what #[derive(Deserialize)] had been doing all along, and a bug that had eaten an hour became obvious in about ten seconds. For declarative macros there’s also trace_macros!(true);, which logs each expansion step as the compiler works through it. Between those two, most of the mystery goes away. If you want to go deep on macro_rules! specifically, The Little Book of Rust Macros is the reference I keep open.

What I’d actually do this week

Don’t try to learn the whole macro system. Pick one repeated pattern in a project you already have, the kind of copy-paste that makes you sigh, and write a single macro_rules! for it. Then run cargo expand and read what it generated. That two-step loop, write a small macro and immediately look at its expansion, is what turned macros from a thing I avoided into a thing I use on purpose. It’s the same habit I lean on across the Rust I ship in my own projects: keep the magic small, and always be able to see through it.

And if you write a macro this week and then delete it next month because a function was clearer, that’s not a failure. That’s the good ending.