Skip to editor content
learningrust.orglesson 22 of 26

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.

Why Macros?

Macros solve problems that regular functions cannot handle:

  • Variable argument countsprintln! 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);
}

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);
}

Breaking down $($element:expr),* $(,)?:

  • $(...) starts a repetition group
  • $element:expr captures each expression
  • , means elements are comma-separated
  • * means zero or more repetitions
  • $(,)? allows an optional trailing comma

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, giving you clean syntax with zero runtime overhead compared to building the map manually.

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

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 lesson 11-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?

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"]

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.

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.

Next lesson

Async/Await

Master asynchronous programming in Rust with async/await syntax, Futures, and the Tokio runtime

25 min