Skip to lesson

learningrust.org / advanced / 22-macros · lesson 22 of 26

TL;DR

Learn how to use and create Rust macros for metaprogramming and code generation at compile time

Key concepts

  • Rust macros tutorial
  • Rust macro rules
  • Rust metaprogramming
  • Rust declarative macros
  • Rust code generation

Macros

Rust's macro system is one of its most distinctive and powerful features. Unlike C/C++ macros, which substitute preprocessing tokens with no awareness of the language's grammar, macro_rules! macros match and generate token trees — structured groups of tokens — so they can require that what you pass is a well-formed expression or type.

You've already been using macros without perhaps realizing it. println!, vec!, assert!, and format! are all macros. The exclamation mark (!) after a name is the telltale sign that something is a macro, not a regular function.

What You'll Learn

By the end of this lesson you will write macros that a function could not replace: one that takes a variable number of arguments and builds a collection from them, one that prints the name of the variable you passed rather than its value, and one that picks a different expansion depending on the shape of the tokens it was handed. You will also fix a macro that quietly evaluates its argument twice, which is the classic way a working macro turns into a bug.

This lesson is not a capstone dependency, and it is worth being straight about that: the capstone's taskwork uses no macros of its own beyond the ones every Rust program uses. What macros buy you is the ability to remove duplication that a function cannot reach — duplication in the shape of the code rather than in its values. You arrive able to read a match expression and predict which arm wins (Pattern Matching), which is the same rule macro arms follow, and knowing what a trait implementation looks like written out by hand (Traits and Generics), which is the boilerplate a derive macro exists to generate.

Why Macros?

Macros solve problems that regular functions cannot handle:

  • Variable argument counts — println! accepts any number of format arguments
  • Code generation — eliminating repetitive boilerplate
  • Domain-specific syntax — creating mini-languages embedded within Rust
  • Compile-time computation — performing work before the program runs

Rust has two main categories of macros: declarative macros (using macro_rules!) and procedural macros (using proc_macro). This lesson focuses on declarative macros, which are the most common and approachable type.

Declarative Macros with macro_rules!

Declarative macros work by matching patterns against input and generating code based on those patterns. Think of them as a powerful match expression, but for Rust syntax itself.

Here is your first macro:

macro_rules! greet {
    () => {
        println!("Hello, World!");
    };
    ($name:expr) => {
        println!("Hello, {}!", $name);
    };
}

fn main() {
    greet!();                      // matches the first arm
    greet!("Rustacean");           // matches the second arm
    greet!(String::from("Alice")); // also matches the second arm
}

The macro has two arms separated by semicolons. The first arm () matches when called with no arguments. The second arm ($name:expr) matches when given any expression.

The $name:expr syntax is called a metavariable. The :expr part is a fragment specifier that tells Rust what kind of syntax to capture. Common specifiers include:

  • expr — any expression
  • ident — an identifier such as a variable or function name
  • ty — a type
  • literal — a literal value like 42 or "hello"
  • stmt — a statement
  • block — a block of code surrounded by {}

Substitution Is Syntactic, Not Evaluated

A macro does not receive a value the way a function does. It receives the tokens you wrote, and it pastes them wherever the metavariable appears in the expansion. That is the whole mental model, and everything surprising about macros falls out of it. Trace this one carefully — the printed lines will tell you exactly how many times the argument ran:

Predict

square! writes $x twice in its expansion, and the argument passed to it is a function call with a side effect. Work out every line this prints, in order, and the two final numbers, before you run it.

macro_rules! square {
  ($x:expr) => {
      $x * $x
  };
}

fn next_id(counter: &mut u32) -> u32 {
  *counter += 1;
  println!("  next_id called, now {}", *counter);
  *counter
}

fn main() {
  let mut counter = 0;

  println!("calling square!(next_id(&mut counter))");
  let result = square!(next_id(&mut counter));

  println!("result  = {}", result);
  println!("counter = {}", counter);
}
Continue learning

The argument runs twice. After expansion the line reads next_id(&mut counter) * next_id(&mut counter), so the left call returns 1, the right returns 2, and the product is 2 — not a square of anything — with counter left at 2. A macro binds no values: it pastes the tokens you wrote into every slot where the metavariable appears. The habit that prevents this costs one line — bind each metavariable to a local exactly once at the top of the expansion and use the local from then on:

macro_rules! square {
    ($x:expr) => {{
        let value = $x;
        value * value
    }};
}

fn next_id(counter: &mut u32) -> u32 {
    *counter += 1;
    *counter
}

fn main() {
    let mut counter = 0;
    println!("result  = {}", square!(next_id(&mut counter)));
    println!("counter = {}", counter);
}

Note the doubled braces: the outer pair turns the expansion into a block expression so it can still be used where a value is expected, and the inner pair is that block's body. This program prints result = 1 and counter = 1.

One thing that is not a problem in Rust, though it is a classic C macro bug: operator precedence. square!(2 + 3) gives 25, not 11. An expr metavariable captures a complete expression and substitutes it as a single unit, so the surrounding * cannot reach inside and grab the 3 on its own — the expansion behaves as if it were parenthesised even though you never wrote parentheses. A C macro has no fragment specifiers, so #define SQUARE(x) x * x pastes the tokens 2 + 3 straight in and the multiplication does reach inside, giving 2 + 3 * 2 + 3. That is what requiring a well-formed expression buys you.

Repeating Patterns

One of the most powerful macro features is repetition, written as $(...)* or $(...)+. This lets you handle a variable number of arguments cleanly.

Let's recreate something similar to the built-in vec! macro:

macro_rules! my_vec {
    ($($element:expr),* $(,)?) => {
        {
            let mut v = Vec::new();
            $(
                v.push($element);
            )*
            v
        }
    };
}

fn main() {
    let numbers = my_vec![10, 20, 30, 40, 50];
    let words = my_vec!["rust", "macros", "are", "powerful"];
    let with_trailing = my_vec![1, 2, 3,]; // trailing comma is fine

    println!("Numbers: {:?}", numbers);
    println!("Words: {:?}", words);
    println!("With trailing comma: {:?}", with_trailing);
}

Two of those choices look like decoration and are not. Rather than reading $($element:expr),* $(,)? left to right, ask what each piece is there instead of.

Why the repetition sits inside the braces rather than around them. The $( … )* wraps only the v.push($element); line — the let mut v and the trailing v are outside it, written once. Wrap the whole block instead and the macro expands to n separate blocks, each building and returning its own one-element vector, which is not an expression at all. rustc says error: macro expansion ignores `{` and any tokens following, and adds the diagnosis: the usage of my_vec! is likely invalid in expression context. The repetition marks the part that varies with the number of arguments; everything that happens once has to sit outside it.

Why $(,)? is worth the noise. It matches an optional trailing comma and does nothing else. Drop it and my_vec![1, 2, 3] still works, but my_vec![1, 2, 3,] — the multi-line form every formatter produces — fails with error: unexpected end of macro invocation, pointing at the final ] and saying missing tokens in macro arguments. The comma separator , between $(...) and * only permits commas between elements; nothing has said anything about one after the last.

Now the third piece is yours, and no note is coming for it. The * says zero or more. Before you change anything, decide what my_vec![] with no arguments should expand to under that *, and what would be different if the macro were written $($element:expr),+ instead. Then try both. One of the two answers is a compile error, and predicting which is the point.

Building Practical Macros

Let's create something genuinely useful: a macro that constructs a HashMap from key-value pairs with clean, readable syntax.

use std::collections::HashMap;

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

fn main() {
    let config = map! {
        "host" => "localhost",
        "port" => "8080",
        "debug" => "true",
    };

    let scores = map! {
        "Alice" => 95_u32,
        "Bob"   => 87_u32,
        "Charlie" => 92_u32,
    };

    println!("Config: {:?}", config);

    let top = scores.iter().max_by_key(|entry| entry.1);
    println!("Top scorer: {:?}", top);
}

This macro expands at compile time into individual .insert() calls. That is worth taking literally rather than as a slogan: map! { "host" => "localhost", "port" => "8080" } becomes

{
    let mut m = HashMap::new();
    m.insert("host", "localhost");
    m.insert("port", "8080");
    m
}

which is character-for-character the block you would have written by hand. The macro is gone before the compiler begins type-checking — there is no map! in the compiled program to be fast or slow, because expansion happens in an earlier phase entirely. This is a different kind of claim from the abstractions in Getting Started, whose cost depended on the optimizer: here there is nothing for an optimizer to remove, because the two sources are the same source by the time anyone looks at them.

A caution about measuring it, since the lane invites you to try. Timing 200,000 constructions each way behind the Run button shows the macro version about 1.1× slower on the first timed round and then even with the hand-written version on every round after (measured: 1.116, then 0.994 and 0.997, with a warm-up pass and std::hint::black_box to stop the work being discarded). The first number is warm-up — allocator and cache behaviour, not code — and reporting it as a cost of the macro would be reading noise as a finding. Two identical statement sequences do not differ in steady state, and that is exactly what the expansion above predicts.

Multiple Pattern Arms

Macros can have many arms, just like match expressions. Rust tries each arm in order and uses the first one that matches. This lets you build macros with flexible calling conventions:

macro_rules! log {
    ($msg:literal) => {
        println!("[INFO] {}", $msg);
    };
    ($level:ident, $msg:literal) => {
        println!("[{}] {}", stringify!($level), $msg);
    };
    ($level:ident, $msg:literal, $val:expr) => {
        println!("[{}] {}: {:?}", stringify!($level), $msg, $val);
    };
}

fn main() {
    log!("Application started");
    log!(WARN, "Memory usage is high");
    log!(ERROR, "Failed to connect", "timeout after 30s");

    let user_count = 42;
    log!(INFO, "Active users", user_count);
}

Notice the use of the built-in stringify! macro, which converts an identifier token into its string representation at compile time — no runtime allocation required. This is a common technique when you want to print the name of something rather than its value. stringify! is only possible because a macro sees your tokens rather than a value: by the time a function runs, the source text that produced its argument is long gone.

That same token-level view is what makes the next bug so quiet. The macro below writes its metavariable in two branches of an if, which looks harmless until the argument does something. It compiles without a warning, runs without a panic, and prints a number that is simply not the right one. Commit a hypothesis before you change anything:

Debug

The first sample is 80 and the limit is 100, so this should report a clamped reading of 80 having consumed exactly one sample. It runs clean and reports 5 and 2. Explain what the expansion actually looks like before you change a line, then fix the macro.

/// Clamps a reading into the range ..=limit. Should evaluate $v exactly ONCE.
macro_rules! clamp_to {
  ($v:expr, $limit:expr) => {
      if $v > $limit { $limit } else { $v }
  };
}

/// A sensor whose every call hands back the NEXT sample and advances.
struct Sensor {
  samples: Vec<i32>,
  position: usize,
}

impl Sensor {
  fn next_reading(&mut self) -> i32 {
      let value = self.samples[self.position];
      self.position += 1;
      value
  }
}

fn main() {
  let mut sensor = Sensor { samples: vec![80, 5, 90], position: 0 };

  // The first sample is 80, comfortably under the limit of 100,
  // so this should report 80 and consume exactly one sample.
  let reading = clamp_to!(sensor.next_reading(), 100);

  println!("clamped reading = {}", reading);
  println!("samples consumed = {}", sensor.position);
}

Expected output: clamped reading = 80 samples consumed = 1

Continue learning

Expanded by hand, that line reads if sensor.next_reading() > 100 { 100 } else { sensor.next_reading() }. The condition consumes the first sample (80), finds it under the limit, and then the else branch consumes the second sample (5) and reports that instead — leaving position at 2. Nothing warns you, because after expansion this is perfectly ordinary Rust. The fix is the same one-line habit: {{ let value = $v; let limit = $limit; if value > limit { limit } else { value } }}. A metavariable that appears more than once in an expansion is a bug waiting for a side-effecting argument, and the discipline of binding it once at the top costs nothing and removes the whole class.

Every macro so far has been a match over syntax: a list of arms, tried top to bottom, first one to fit wins. Close the page and answer this from memory before reading on:

Recall

Without scrolling up: in Pattern Matching you learned how Rust picks which match arm runs, and what happens to an arm that a broader arm above it already covers. The log! macro earlier in this lesson lists its arms in the order ($msg:literal), then ($level:ident, $msg:literal), then ($level:ident, $msg:literal, $val:expr). What decides which arm handles a given call, and what would happen if the three-argument arm were moved to the TOP of the list?

Continue learning

Macro arms are match arms over syntax: tried in written order, first fit wins, exactly one expands — there is no specificity ranking anywhere in Rust. In log! the three patterns have incompatible shapes, so reordering them changes nothing. But the moment one arm is a repetition like $($x:expr),*, it is broad enough to swallow calls that a later, narrower arm was written for, and it must go last — the same hazard as putting a catch-all _ => at the top of a match in Pattern Matching.

Try It Yourself

A worked recursive example first, since recursion is the one macro shape that has no analogue in ordinary code. A min! macro finds the minimum among any number of arguments using a base case for a single value and a recursive case that peels one value off the front:

macro_rules! min {
    // Base case: a single value is the minimum of itself
    ($x:expr) => ($x);
    // Recursive case: compare the first value with the min of the rest
    ($x:expr, $($rest:expr),+) => {
        std::cmp::min($x, min!($($rest),+))
    };
}

fn main() {
    let smallest = min!(5, 3, 8, 1, 9, 2);
    println!("Smallest of 5,3,8,1,9,2 is: {}", smallest);

    let a = min!(100, 42);
    println!("Smaller of 100 and 42 is: {}", a);
}

The $($rest:expr),+ pattern uses + instead of *, requiring at least one additional argument beyond the first. The macro calls itself, peeling off one value at a time until it reaches the single-value base case — and note the arm order, which matters here for exactly the reason the Retrieval established: the recursive arm demands at least two arguments, so it cannot swallow the one-argument call the base case is written for.

Now write some yourself. This is a build task: a small program that reports its own pass/fail. Three macro stubs are given, each already matching the calls in main and expanding to a placeholder of the right type, so the program compiles as shipped and fails on its checks rather than in the compiler. Run it as-is to see which check fails first, then replace each placeholder body until it prints All checks passed.

The three exercise the three things this lesson taught and nothing else: recursion with a base case (as min! just did), a $(...),* repetition, and stringify! reaching the source text a function could never see. Watch the separator in joined! — its pattern uses ; between the separator and the parts, which is how a macro gives itself a calling convention no function signature could express.

Build

Finish the build. Three macros are stubbed out and the checks below them fail until each one expands correctly. Run it as-is to see which check fails first, decide what that macro's body is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so work down from the first failure you see.

// Three macros to finish. Each stub already MATCHES the calls in main and
// expands to a placeholder of the right type, so the program compiles as
// shipped and fails its checks. Replace each placeholder body.

// TODO 1: return the NUMBER of arguments passed, as a usize.
//   Two arms: an empty one (already correct), and a recursive one that peels
//   off the first argument and adds 1 to count_args! of the rest.
//   count_args!() -> 0    count_args!(1, 2, 3, 4) -> 4
macro_rules! count_args {
  () => { 0usize };
  ($first:expr $(, $rest:expr)*) => {{
      let _ = $first;
      $( let _ = $rest; )*
      0usize
  }};
}

// TODO 2: join every part into one String, separated by $sep.
//   The parts may be any Display type, so call .to_string() on each.
//   Build a Vec<String> with a repetition, then finish with .join($sep).
//   joined!("-"; "a", "b", "c") -> "a-b-c"
macro_rules! joined {
  ($sep:expr; $($part:expr),* $(,)?) => {{
      let _ = $sep;
      $( let _ = $part; )*
      String::new()
  }};
}

// TODO 3: return a Vec<String>, one entry per argument, each formatted as
//   "<the argument's SOURCE TEXT> = <its value>". stringify! gives you the
//   source text; {:?} formats the value.
//   let width = 4; named!(width, 2 + 2) -> ["width = 4", "2 + 2 = 4"]
macro_rules! named {
  ($($value:expr),* $(,)?) => {{
      $( let _ = $value; )*
      Vec::<String>::new()
  }};
}

// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
  assert_eq!(count_args!(), 0, "count_args!() with no arguments should be 0");
  assert_eq!(count_args!("a"), 1, "count_args! with one argument should be 1");
  assert_eq!(count_args!(1, 2, 3, 4), 4, "count_args! should count every argument");

  assert_eq!(
      joined!("-"; "a", "b", "c"),
      "a-b-c".to_string(),
      "joined! should join every part with the separator"
  );
  assert_eq!(
      joined!(", "; 1, 2, 3),
      "1, 2, 3".to_string(),
      "joined! should work for any Display type, not just &str"
  );

  let width = 4;
  assert_eq!(
      named!(width, 2 + 2),
      vec!["width = 4".to_string(), "2 + 2 = 4".to_string()],
      "named! should print each expression's SOURCE TEXT alongside its value"
  );

  println!("All checks passed.");
  println!("count_args!(1, 2, 3, 4) = {}", count_args!(1, 2, 3, 4));
  println!("joined!('-'; 'a', 'b', 'c') = {}", joined!("-"; "a", "b", "c"));
  println!("named!(width, 2 + 2) = {:?}", named!(width, 2 + 2));
}

Expected output: All checks passed. count_args!(1, 2, 3, 4) = 4 joined!('-'; 'a', 'b', 'c') = a-b-c named!(width, 2 + 2) = ["width = 4", "2 + 2 = 4"]

Continue learning

Once it passes, try two variations and predict each before running:

  1. Reorder count_args!'s arms. Move the recursive arm above the empty one. Predict whether anything changes before running. Nothing does: the recursive arm's pattern requires at least one argument, so count_args!() cannot match it and falls through to the base case regardless of order. First-fit ordering only bites when an earlier arm is broad enough to fit a later arm's calls — which is exactly why the empty arm being narrower makes this reordering safe.
  2. Drop stringify!. In named!, replace stringify!($value) with $value. Decide what the two entries become before running. They become "4 = 4" and "4 = 4" — both expressions evaluate to 4, and without stringify! there is nothing left to distinguish width from 2 + 2. That difference is the entire reason stringify! exists, and the reason it can only be a macro.

Which Arm Wins

The retrieval question above asked what happens when a broad arm moves to the top. Here is the same rule as a construction task, with a repetition arm in the mix.

Arrange the code

These four arms belong to one macro_rules! macro. Each prints a different word so you can see which one handled each call. The macro is then called four times, in this order: with the literal start, with a different literal, with an arithmetic expression, and with two arguments. Put the arms in the order that prints BOOT INFO EXPR LIST LIST — then answer the real question: what decides which arm handles a call, and why does every wrong order still compile?

  1. ($($item:expr),*) => { $( { let _ = $item; print!("LIST "); } )* };
  2. ("start") => { print!("BOOT "); };
  3. ($one:expr) => { print!("EXPR "); };
  4. ($msg:literal) => { print!("INFO "); };
Continue learning

Transfer

The C preprocessor also rewrites source before the compiler proper sees it, and a C programmer meeting macro_rules! will reach for that comparison immediately. Both operate on your text before the program means anything. Which statement names what genuinely transfers, rather than a surface resemblance?

Continue learning

Key Takeaways

  • Rust macros match and generate token trees at compile time; C macros substitute tokens with no awareness of the language's grammar
  • The ! suffix distinguishes macro calls from function calls
  • macro_rules! defines declarative macros using pattern matching with multiple arms
  • Metavariables ($name:fragment) capture different kinds of Rust syntax
  • The $(...)* repetition syntax handles variable numbers of arguments
  • Trailing comma support is a common ergonomic convention: $(,)?
  • Macros can recurse, enabling patterns like processing lists one element at a time
  • Built-in macros like vec!, println!, and assert! follow exactly the same rules as your own
  • A macro substitutes tokens, not values: a metavariable written twice in an expansion runs its argument twice, so bind each one to a local at the top of the expansion — {{ let v = $x; … }} — and use the local from then on
  • An expr metavariable captures a complete expression and substitutes it as a single unit, so the C precedence trap does not exist here: square!(2 + 3) is 25, not 11
  • Arms are tried in written order and the first fit wins, exactly like match — a broad repetition arm must go last or nothing below it can ever run

Pro Tip: When debugging a macro, install cargo-expand with cargo install cargo-expand and run cargo expand in your project. This prints the fully expanded source code, showing exactly what Rust compiles after all macros have been applied. It is the single most effective tool for understanding why a macro is generating unexpected output.

Next Steps

With macros understood, you're ready for async/await — Rust's approach to writing efficient, non-blocking concurrent code.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.