Skip to content
Home
Rust Macros: Declarative & Procedural Guide

Rust Macros: Declarative & Procedural Guide

Rust Rust 9 min read 1716 words Intermediate ExcellentWiki Editorial Team

Introduction to Rust Macros

Rust macros enable metaprogramming — writing code that writes other code at compile time, before type checking and monomorphization. This allows abstraction patterns that would be impossible with functions alone, including syntax extension, boilerplate elimination, and embedding domain-specific languages (DSLs). Rust provides two fundamentally different macro systems: declarative macros (defined with macro_rules!) which pattern-match on token trees and expand them using a template, and procedural macros which are compiled Rust functions that transform token streams programmatically. Both systems expand at compile time, meaning generated code is fully type-checked, borrow-checked, and optimized identically to hand-written code. The choice between them depends on complexity: simple pattern-based code generation uses macro_rules!, while complex transformations requiring full Rust syntax parsing use procedural macros. The Rust Reference dedicates a comprehensive chapter to macro expansion rules, hygiene, and scoping (The Rust Reference, “Macros,” doc.rust-lang.org/reference/macros.html). Macros are used throughout the ecosystem — from println! and vec! to serde_json::json! and tokio::main — making them an indispensable part of Rust programming.

Declarative Macros (macro_rules!)

Declarative macros, also called “macros by example,” use pattern matching to transform input token sequences into output token sequences. Each macro has one or more arms called rules, where each rule consists of a pattern and a template. The pattern uses special matcher types: $var:expr captures an expression, $var:ty captures a type, $var:ident captures an identifier, $var:pat captures a pattern, $var:stmt captures a statement, and $var:tt captures a single token tree. Repetition is specified with $() using * (zero or more) or + (one or more) qualifiers, with an optional separator like , between repetitions:

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

let map = hashmap!("one" => 1, "two" => 2, "three" => 3,);

The $(,)? pattern handles trailing commas, a common usability requirement for Rust macros. Without it, trailing commas cause a compile error. This pattern appears throughout the standard library’s macros, including vec! and HashMap!.

Repetition Patterns in Depth

Repetition is the most powerful feature of declarative macros, enabling generation of arbitrarily many items based on the input. The pattern $($field:ident: $type:ty),* captures comma-separated pairs of field names and types, and the template can use $field and $type inside the repetition block separately:

macro_rules! build_struct {
    ($name:ident { $($field:ident: $type:ty),* $(,)? }) => {
        struct $name { $($field: $type),* }
        impl $name {
            fn new($($field: $type),*) -> Self {
                Self { $($field),* }
            }
        }
    };
---

build_struct!(Config { host: String, port: u16, timeout: Duration });

Declarative macros support recursive patterns, enabling complex code generation like recursive descent parsers and nested data structure builders. However, recursion depth is limited by the compiler’s recursion limit (configurable with #![recursion_limit = "256"]). Macros can also delegate to other macros, creating powerful composition patterns.

Macro Hygiene and Scoping

Declarative macros are hygienic: identifiers introduced inside the macro body do not leak into the calling scope. This prevents accidental name collisions between macro-generated code and the calling context. However, the macro can access identifiers passed in from the call site through its parameters. The $crate metavariable is critical for macros intended for use across crate boundaries — it resolves to the crate where the macro is defined, ensuring that paths in the generated code resolve correctly regardless of where the macro is invoked. Without $crate, a Vec reference in macro output would resolve in the calling crate’s namespace rather than the macro crate’s. The #[macro_export] attribute makes a macro available to downstream crates, and #[macro_use] imports macros from external crates.

Procedural Macros

Procedural macros are Rust functions that run at compile time and operate on token streams. They are defined in separate crates with proc-macro = true in Cargo.toml. There are three kinds: derive macros (#[derive(Trait)]), attribute macros (#[attribute(args)]), and function-like macros (my_macro!(...)). Procedural macros can parse arbitrary Rust syntax using the syn crate and generate code using the quote crate. Unlike declarative macros, they have full access to the Rust type system and can implement arbitrarily complex logic.

Derive Macros

Derive macros automatically implement a trait for the annotated type:

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    let ast = syn::parse(input).unwrap();
    impl_hello_macro(&ast)
---

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let gen = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, {}!", stringify!(#name));
            }
        }
    };
    gen.into()
---

When to Use Macros vs Generics vs Functions

The Rust community recommends a clear hierarchy: prefer functions for runtime logic, generics for type-polymorphic logic, and traits for interface abstraction. Consider macros only when your abstraction requires operating on syntax rather than values, generating boilerplate for structurally varying types, performing compile-time validation, or creating DSL syntax. The Rust API Guidelines recommend minimizing macro surface area because macros are harder to debug, test, and maintain than regular functions. Macros should be the tool of last resort, used only when functions, generics, and traits cannot achieve the desired abstraction.

Common Macro Patterns in the Ecosystem

Several macro patterns appear frequently in production Rust code. Builder patterns use macros to generate builder structs with typed builder methods, eliminating runtime checks. Enum variant matching macros generate exhaustive match arms for enums, ensuring all variants are handled. Serialization boilerplate macros generate From and TryFrom implementations for wrapper types. Test parameterization macros generate test functions with different input values, replacing repetitive test code. The matches! macro from the standard library simplifies pattern matching in boolean contexts. The todo! and unreachable! macros provide explicit placeholder and invariant enforcement. The dbg! macro provides debug printing with file and line information. Understanding these common patterns helps identify opportunities for macro-based refactoring and avoids reinventing well-established macro designs. Many of these patterns originated in popular crates like serde, thiserror, strum, and derive_more, each demonstrating idiomatic macro usage worth studying.

Debugging Macro Expansion Errors

Macro errors can produce cryptic compiler messages. The cargo expand command (from cargo-expand crate) shows the fully expanded code, making it possible to verify macro output independently of the consuming context. For macro_rules! debugging, the log_syntax! macro prints the macro’s input at compile time, and trace_macros!(true) enables compiler tracing of macro expansion. The stringify! macro converts token trees to string literals for inspection. When a macro produces type errors in the calling code, isolate the macro invocation with explicit type annotations to identify which generated expression has incorrect types. Recursive macro debugging benefits from setting #![recursion_limit = "256"] higher temporarily to see if the limit was the issue. Common macro bugs include: missing separator patterns causing unexpected concatenation, incorrect repetition counts from mismatched * and + quantifiers, and hygiene issues where identifiers from the macro body leak or fail to capture properly. The $crate prefix resolves path resolution issues across crate boundaries, one of the most common macro portability problems.

Performance Characteristics and Compile Time

Macro expansion occurs during compilation, so expensive macros directly increase build times. Each macro_rules! invocation requires pattern matching across all arms sequentially, making large macros with many arms slower. Recursive macros with deep recursion hit the recursion_limit (default 128) and can cause quadratic compile times. The trace_macro! compiler flag measures expansion cost. For hot code paths where macros expand inside loops, consider whether the macro could be a function instead — function calls have zero compile-time impact.

Declarative macros cannot generate items with names derived from macro arguments without the paste crate for identifier concatenation. The seq_macro crate generates repetitive code at compile time without recursion overhead. For proc macros, the proc_macro2 span manipulation API controls error location reporting. The compile_error! macro within an expanded macro stops compilation with a custom message. Release builds with lto = true and codegen-units = 1 can optimize across macro-generated boundaries better than debug builds. When macros contribute significantly to build times, conditional compilation (#[cfg]) inside macro output reduces generated code for unused features. The modular macro pattern splits large macro functionality into multiple cooperating macros, improving both maintainability and compile-time parallelism.

Frequently Asked Questions

What is the $crate metavariable? $crate resolves to the crate identifier where the macro is defined, ensuring paths in macro output resolve correctly when the macro is used from other crates.

Can macros generate new identifiers? Declarative macros capture identifiers with $name:ident but cannot create new ones. Procedural macros can create fresh identifiers using proc_macro2::Ident::new.

How do I debug macro-generated code? Use cargo expand to see the expanded code. Use eprintln! inside procedural macros to print token streams during compilation.

What is the difference between expr and tt in macro_rules! patterns? expr matches a Rust expression. tt matches a single token tree — any single token or balanced delimiter group.

When should I NOT use a macro? When a function, generic, or trait would suffice. Macros increase compile times, complicate error messages, and reduce code clarity.

Related: Advanced Rust Macros | Rust Serialization with Serde | Rust Unsafe Code

Rust in Production: Case Studies

Rust has been adopted in production by major technology companies for performance-critical and safety-critical systems. Mozilla uses Rust in the Firefox browser engine (Servo/Quantum) for improved memory safety and performance in CSS layout and rendering. Dropbox rewrote their sync engine core in Rust, achieving significant performance improvements and eliminating entire classes of bugs. Cloudflare uses Rust for edge computing infrastructure, including their Pingora HTTP proxy that handles 10% of the world’s internet traffic. Figma rewrote their multiplayer editing engine in Rust, achieving predictable performance and eliminating crashes. Discord used Rust for their read states service, handling millions of concurrent connections with minimal resources. These case studies demonstrate Rust’s value in systems where performance, reliability, and memory safety are critical. The common pattern: rewrite performance-critical components in Rust while maintaining the rest of the system in the original language, proving that incremental adoption works in practice.

Embedded Systems Programming

Rust is gaining traction in embedded systems for its memory safety guarantees without a garbage collector or runtime. The cortex-m-quickstart template provides a starting point for ARM Cortex-M microcontrollers. The RTIC framework provides real-time interrupt-driven concurrency. Embassy offers an async embedded runtime with USB, networking, and BLE support. Rust’s type system prevents common embedded errors like misconfiguring peripherals, incorrect register access, and data races between interrupt handlers and main code. The embedded ecosystem continues to mature with HAL implementations for major microcontroller families from STM, Nordic, and Espressif.

Section: Rust 1716 words 9 min read Intermediate 756 articles in section Report inaccuracy Back to top