What Are Macros in Rust and Why Are They Useful?

Macros in Rust are compile-time code generators that reduce boilerplate and enable flexible, reusable patterns.

When functions hit a wall

You are building a logging crate. You want a single call that captures the file name, line number, log level, and a formatted message. You try writing a function. The compiler stops you. Functions don't know where they are called. They can't inspect the source code that invokes them. You need something that runs before the compiler turns your code into machine instructions. You need a macro.

Macros solve problems that functions cannot touch. They let you generate code based on patterns, accept variable numbers of arguments, and access source-level metadata like file names and line numbers. They are the tool you reach for when the abstraction you need lives in the structure of the code itself, not just in the data flowing through it.

Macros are code that writes code

Macros are text transformers. Before the compiler checks types or generates code, the macro system takes your macro call and replaces it with expanded Rust code. Think of a macro as a template that fills in blanks based on what you pass it. The result is valid Rust code that the compiler then treats exactly like code you typed by hand.

There is no runtime cost. The expansion happens at compile time. The expanded code lives in your binary. When you call a macro, you are not invoking a function at runtime. You are asking the compiler to generate code for you. The generated code runs just like any other code.

Rust has two kinds of macros. Declarative macros use macro_rules! and are built into the language. They match patterns and expand to code. Procedural macros are functions that run during compilation and return generated code. They are more powerful but require a separate crate and more setup. Most Rust code uses macro_rules! macros. They cover the vast majority of use cases and are easier to write and debug.

A minimal macro

The entry point for macros is macro_rules!. It defines a name, a set of patterns to match, and the code to expand to.

// Define a macro named `say_hello`.
// The `macro_rules!` keyword tells the compiler this is a declarative macro.
macro_rules! say_hello {
    // Match an empty input: `()`
    () => {
        // Expand to this block of code
        println!("Hello!");
    };
}

fn main() {
    // Call the macro with `!`.
    // This expands to `println!("Hello!");` before compilation.
    say_hello!();
}

The macro definition has a name, say_hello, followed by braces containing rules. Each rule has a pattern on the left and an expansion on the right, separated by =>. The pattern () matches an empty pair of parentheses. When you call say_hello!(), the compiler matches the input against the pattern and replaces the call with println!("Hello!");.

Convention aside: macro names use snake_case, just like functions. The exclamation mark is part of the call syntax, not the definition. You define macro_rules! say_hello, but you call say_hello!(). This distinguishes macro calls from function calls in the source code.

How expansion works

When the compiler encounters a macro call, it performs macro expansion. It looks up the macro definition and compares the input tokens against the patterns. If a pattern matches, the compiler substitutes the expansion. The expanded code is then fed back into the compiler for type checking and code generation.

If the expansion produces invalid code, you get a compiler error. The error points to the macro call, but the message refers to the expanded code. This can be confusing. The error might mention variables or types that don't appear in your source file because they exist only in the expansion.

If you forget the exclamation mark, the compiler rejects you with E0423 (expected function, found macro). Macros must be invoked with !. The compiler distinguishes macros from functions by this syntax.

Convention aside: when debugging macro errors, use cargo expand. This tool shows the fully expanded code. It helps you see what the macro actually generated. Add cargo-expand as a dev-dependency and run cargo expand to inspect the output.

Patterns and repetition

Macros become powerful when they match complex patterns and repeat code. Patterns use fragment specifiers to capture parts of the input. Common specifiers include expr for expressions, ident for identifiers, and ty for types.

// A macro that logs a value with its type name.
// This demonstrates capturing an expression and an identifier.
macro_rules! log_value {
    // Match an identifier and an expression
    ($name:ident, $val:expr) => {
        // Expand to a println that prints the name and value
        println!("{} = {:?}", stringify!($name), $val);
    };
}

fn main() {
    let count = 42;
    // Expands to: println!("count = {:?}", "count", count);
    log_value!(count, count);
}

The pattern $name:ident captures an identifier and binds it to $name. The pattern $val:expr captures an expression and binds it to $val. The expansion uses these bindings. stringify!($name) converts the identifier to a string literal. This is a built-in macro that helps with debugging.

Macros can also handle repetition. The syntax $( ... )* matches zero or more repetitions of the inner pattern. This lets you write macros that accept variable numbers of arguments.

// A macro that creates a vector from a list of expressions.
// This mimics the behavior of the built-in `vec!` macro.
macro_rules! my_vec {
    // Match zero or more expressions separated by commas
    ( $( $x:expr ),* ) => {
        {
            // Create a temporary vector
            let mut temp_vec = Vec::new();
            // Push each matched expression into the vector
            $(
                temp_vec.push($x);
            )*
            // Return the vector
            temp_vec
        }
    };
}

fn main() {
    // Expands to a block that pushes 1, 2, and 3 into a vector
    let numbers = my_vec!(1, 2, 3);
    println!("{:?}", numbers);
}

The pattern $( $x:expr ),* matches a comma-separated list of expressions. Each expression is bound to $x. The repetition $( ... )* in the expansion repeats the block for each matched expression. This generates temp_vec.push(1); temp_vec.push(2); temp_vec.push(3);.

Convention aside: when writing repetition macros, always wrap the expansion in a block { ... }. This ensures the macro can be used in expression contexts and avoids semicolon issues. The block returns the final value, making the macro behave like an expression.

Hygiene keeps you safe

Macros in Rust are hygienic. This means identifiers in the macro expansion do not capture variables from the call site unless explicitly told to. Hygiene prevents bugs where a macro accidentally shadows a variable or introduces a name collision.

// A macro that defines a local variable `x`.
// Hygiene ensures this `x` does not affect the caller.
macro_rules! define_x {
    () => {
        // This `x` is local to the macro expansion.
        let x = 42;
        println!("Inside macro: {}", x);
    };
}

fn main() {
    let x = 10;
    // The macro defines its own `x`. It does not see the caller's `x`.
    define_x!();
    // The caller's `x` is unchanged.
    println!("Outside macro: {}", x);
}

The macro defines let x = 42;. Because of hygiene, this x is scoped to the macro expansion. It does not shadow the x in main. The output shows Inside macro: 42 and Outside macro: 10. In C-style macros, this would cause a collision or unexpected behavior. Rust's hygiene prevents this automatically.

Hygiene also applies to file!() and line!(). These built-in macros capture the source location of the call site, not the macro definition. This is a special exception to hygiene that allows macros to provide context. You can use file!() and line!() in your own macros to log where they were called.

Convention aside: trust hygiene. It saves you from shadowing bugs that would haunt you in other languages. If you need to capture a variable from the call site, use $crate or explicit bindings. Do not rely on implicit capture.

Pitfalls and error messages

Macros add complexity to the compilation process. Error messages can be cryptic because they refer to expanded code. If a macro expands to invalid syntax, the compiler reports an error at the macro call, but the message might mention tokens that don't appear in your source.

Use cargo expand to debug macro errors. It shows the expanded code, making it easier to spot syntax errors or type mismatches. If the expanded code looks correct but the compiler complains, check for hygiene issues or trait bounds that are not satisfied in the expansion.

Another pitfall is overuse. Macros are powerful, but they should not replace functions or traits. Functions are easier to read, debug, and optimize. Traits provide polymorphism without code generation. Macros are best reserved for cases where functions and traits fall short.

Convention aside: keep macros small and focused. A macro that does too much becomes hard to maintain. Break complex macros into smaller helper macros. This improves readability and makes debugging easier.

When to reach for macros

Use macros when you need to generate code that depends on the source location, like logging file names and line numbers. Use macros when you need to accept a variable number of arguments, such as a vec! macro that handles any number of elements. Use macros when you want to create a domain-specific syntax that feels native to Rust, like a match-like pattern for a custom type. Use functions when the logic is pure computation and doesn't need to inspect or generate code structure. Use traits when you need polymorphism based on types rather than code generation. Use traits when you want to add behavior to existing types without modifying their source code.

Macros are a hammer. Don't use them to drive screws. If you can write it as a function, write it as a function. Macros add complexity to the compilation process and error messages. Reach for macros only when the problem requires code generation or source-level introspection.

Where to go next