Macros

You've been using macros since your very first Rust program — `println!` is one, and so are `vec!`, `assert_eq!`, and `#[derive(...)]`. That telltale exclamation mark, and those `#[...]` attributes, mark code that isn't a normal function call but metaprogramming: code that writes code at compile time. Macros are how Rust does the powerful, boilerplate-eliminating tricks that would need runtime reflection or code generators in other languages — all checked at compile time. This closing post of the series demystifies them.

This final post of Rust from the Ground Up covers macros — Rust’s metaprogramming feature (code that writes code at compile time). It covers what macros are, the difference from functions, declarative macros (macro_rules!), a conceptual look at procedural macros (like #[derive(...)]), and when to use macros. Macros are an advanced topic; this post demystifies the macros you’ve used throughout the series and gives a foundation for understanding (and occasionally writing) them. It’s a fitting capstone.

What macros are

Macros are Rust’s metaprogramming feature — code that writes code, expanded at compile time. You’ve used them all along:

Macros are Rust’s compile-time metaprogramming — code that writes code, expanded during compilation — and you’ve used them throughout (println!, vec!, assert_eq!, #[derive(...)], marked by ! or #[...]). They provide power beyond functions (eliminating boilerplate, generating code, flexible syntax) while staying compile-time and type-checked. Understanding how they differ from functions clarifies why they exist.

Macros vs functions

Macros differ from functions in important ways — understanding the difference explains what macros can do that functions can’t:

Macros differ from functions fundamentally: they operate on code (tokens, at compile time) rather than values (at runtime), which lets them do what functions can’t — take variable arguments, generate code (like trait implementations), and create flexible syntax — at the cost of greater complexity. This is why macros exist. Rust has two kinds — declarative and procedural.

Declarative macros: macro_rules!

Declarative macros (defined with macro_rules!) are the simpler, more common kind — they work by pattern matching on code and generating code from templates:

// A simple declarative macro that creates a Vec and pushes given elements.
macro_rules! my_vec {
    // Match: any number of comma-separated expressions.
    ( $( $x:expr ),* ) => {
        {
            let mut temp = Vec::new();
            $(
                temp.push($x);
            )*
            temp
        }
    };
}

fn main() {
    let v = my_vec![1, 2, 3]; // expands to code creating a Vec with 1, 2, 3
    println!("{v:?}");         // [1, 2, 3]
}

Declarative macros (macro_rules!) work by pattern-matching on code and generating code from templates (like match on code structure), using $(...)* for repetition to handle variable arguments — the simpler, more common, approachable kind of macro (essentially how vec! works). The more powerful kind is procedural macros.

Procedural macros (and when to use macros)

Procedural macros are the more powerful, more complex kind — they’re Rust functions that operate on code as data, and they power features like #[derive(...)]:

Procedural macros are the more powerful, complex kind — functions operating programmatically on code (token streams), powering #[derive(...)] and attribute macros (generating trait implementations, like thiserror’s) — while declarative macros (macro_rules!) handle simpler pattern-template cases. Use macros judiciously (prefer functions/generics when they suffice; reach for macros for genuine metaprogramming). This completes Module 3 and the Rust from the Ground Up series — from ownership to fearless concurrency, async, testing, and metaprogramming: the foundations of writing real, correct, idiomatic Rust.

Key takeaways

Further reading

Sources & References

Declarative and procedural macros