Rust Macros: A Way for Writing Code That Writes Code
Yashwant Sanjay Saste24 min read·Just now--
From your first
println!to building your own DSL — everything you need to understand, use, and master Rust macros.
The Exclamation Mark That Started It All
The first time most developers encounter Rust, they type something like this:
fn main() {
println!("Hello, Rust!");
}It works. It prints. You feel good. But then, maybe a few hours later, you pause and think:Wait. Why does println have an exclamation mark? Is that a typo?
That little ! is one of the most important symbols in Rust. It's not an accident, it's not excitement, and it's definitely not a typo. It's Rust's way of telling you:
“This is not a regular function. This is a macro.”
And once you understand what that means — once you really get why that ! exists — a whole new dimension of Rust programming opens up.
1. What Are Macros in Rust?
A macro in Rust is a way to write code that generates other code.
That might sound abstract, so let’s use an analogy.
Imagine you’re a baker who makes sandwiches. A regular function is like a trained baker — you give them bread and fillings, and they make you a sandwich. The baker follows a fixed process. You can’t change how they work, just what you give them.
A macro is like a recipe template generator. Instead of making a sandwich, it writes a new recipe for you. You describe the pattern, and the macro creates the actual instructions that will then be followed. Before any baking (running) happens, the recipe (code) is already written.
In Rust terms:
- Functions are compiled, called at runtime, and execute logic using values.
- Macros are expanded at compile time, transforming your source code into different (usually more) source code before the compiler even starts compiling it.
Your source code
↓
[Macro Expansion] ← happens here, before compilation
↓
Expanded source code
↓
[Compilation]
↓
Binary / ExecutableThis is the key insight. When you write println!("Hello"), the compiler doesn't just call a function named println. It first expands that macro into a block of real Rust code — a whole set of formatting instructions — and then compiles that expanded code.
You never see the expanded code unless you go looking for it. It happens silently, before your eyes.
That’s why macros feel a little magical at first. The code you write is not quite the code that gets compiled. Macros are the bridge between the two.
2. Why Rust Needs Macros
If Rust is so powerful, why aren’t regular functions enough?
Great question. Let’s look at some concrete limitations.
Problem 1: Functions Can’t Accept a Variable Number of Arguments
In many languages, you can write something like:
print("a", "b", "c", "d") # Python handles this fineIn Rust, a function must have a fixed, typed signature. You cannot define a regular function that accepts one, two, three, or ten arguments of potentially different types. Rust’s strict type system simply doesn’t allow it.
But this works perfectly fine:
println!("{} {} {} {}", "a", "b", "c", "d");Why? Because println! is a macro. It doesn't have a fixed signature — it's a pattern matcher that generates the right code depending on whatever you pass it.
Problem 2: Functions Can’t Inspect Their Own Syntax
Macros receive tokens — the raw text of your code — before type checking. This lets them manipulate syntax itself.
Consider vec!:
let v = vec![1, 2, 3, 4, 5];This creates a Vec<i32> initialized with five elements. To replicate this as a function, you'd need something clunky:
let v = Vec::from([1, 2, 3, 4, 5]); // A bit verbose and less flexibleThe vec! macro is cleaner, more flexible, and can handle different types automatically. You couldn't write it as a regular function without losing clarity.
Problem 3: Functions Can’t Reduce Boilerplate at a Structural Level
Imagine you’re implementing a trait for twenty different types. Every implementation looks nearly identical — just the type name changes. With a macro, you can write the pattern once and stamp it out for all twenty types automatically.
Problem 4: Functions Can’t Create Domain-Specific Languages
Some of the most powerful uses of macros are in creating mini-languages embedded inside Rust:
route!("/home" => home_handler);
route!("/login" => login_handler, method = POST);This kind of expressive, readable syntax isn’t possible with regular functions. Macros let you define your own syntax rules.
3. Macro vs Function: A Clear Comparison
Before diving into how to write macros, let’s make sure the macro vs. function distinction is crystal clear.
Side-by-Side Example
Here’s a simple add function:
fn add(a: i32, b: i32) -> i32 {
a + b
}
let result = add(3, 4); // works fineAnd here’s a macro that does something similar:
macro_rules! add {
($a:expr, $b:expr) => {
$a + $b
};
}
let result = add!(3, 4); // also worksThey look similar, but there are deep differences:
When to Use Which
Use a function when:
- Your logic is self-contained and takes fixed inputs
- You want clear, debuggable behavior
- Type safety and IDE support matter most
Use a macro when:
- You need variable argument counts
- You’re generating repetitive code
- You want custom syntax or DSL-like expressiveness
- Compile-time code generation is needed
4. Declarative Macros: macro_rules!
There are two broad types of macros in Rust:
- Declarative macros — defined with
macro_rules! - Procedural macros — more advanced, defined as separate crates
We’ll spend most of our time on declarative macros because they’re where every Rust developer starts. They’re powerful, they’re approachable, and they cover 80% of macro use cases.
Your First Macro
macro_rules! say_hello {
() => {
println!("Hello from a macro!");
};
}
fn main() {
say_hello!(); // prints: Hello from a macro!
}Let’s dissect every symbol:
macro_rules! say_hello {
│ │
│ └── The name of the macro (used as say_hello!)
└── The keyword that defines a declarative macro
() => {
│ │
│ └── The expansion block: what code gets generated
└── The pattern to match against (empty here - no arguments)
println!("Hello from a macro!");
};
│
└── Semicolon ends this match arm (like a match arm in Rust's match expression)
}The structure of macro_rules! is essentially a series of pattern => expansion arms, very similar to Rust's match expression.
When you write say_hello!(), Rust looks at the argument list — in this case, nothing — and finds the arm whose pattern matches. It then replaces your macro call with the content of the expansion block.
After expansion, the compiler sees:
fn main() {
println!("Hello from a macro!"); // ← this is what the compiler actually compiles
}You wrote say_hello!(). The compiler compiled println!(...). The macro was the transformation in between.
Multiple Arms
Just like match, macro_rules! can have multiple arms:
macro_rules! greet {
() => {
println!("Hello, stranger!");
};
($name:ident) => {
println!("Hello, {}!", stringify!($name));
};
}fn main() {
greet!(); // Hello, stranger!
greet!(Alice); // Hello, Alice!
}Rust checks each arm from top to bottom and uses the first one that matches.
5. Fragment Specifiers: The Grammar of Macros
This is one of the most important sections in this article. Read it carefully.
When you write a macro that accepts input, you need to tell Rust what kind of thing you’re accepting. These are called fragment specifiers, and they’re written after the : in a pattern like $x:expr.
Think of fragment specifiers as types for macro patterns. Just as i32 tells Rust "this function parameter is an integer," expr tells Rust "this macro parameter is an expression."
Let’s go through all the major ones.
expr — An Expression
macro_rules! double {
($x:expr) => {
$x * 2
};
}let result = double!(5 + 3); // expands to: (5 + 3) * 2 = 16Matches any Rust expression: a literal, a function call, a math operation, a closure, a block — basically anything that produces a value.
Common confusion: expr captures the whole expression as a unit. So double!(5 + 3) expands to (5 + 3) * 2, not 5 + 3 * 2. The expression is preserved intact.
ident — An Identifier
macro_rules! create_variable {
($name:ident, $value:expr) => {
let $name = $value;
};
}create_variable!(my_var, 42);
// expands to: let my_var = 42;
println!("{}", my_var); // prints: 42Matches any valid Rust identifier: variable names, function names, type names, etc. It does not match keywords like fn or let.
Common confusion: Beginners sometimes try to use $name:expr when they want a variable name. Use ident for names, expr for values.
ty — A Type
macro_rules! create_vec_of_type {
($t:ty) => {
Vec::<$t>::new()
};
}let v: Vec<i32> = create_vec_of_type!(i32);Matches a Rust type — i32, String, Option<bool>, Vec<u8>, etc.
Use case: Generating code for specific types, like implementing traits.
stmt — A Statement
macro_rules! log_and_run {
($s:stmt) => {
println!("Running statement...");
$s
};
}log_and_run!(let x = 5);Matches a complete statement: a let binding, an expression statement, etc.
Use case: Wrapping statements with before/after logic (like timing or logging).
block — A Block of Code
macro_rules! time_it {
($b:block) => {
let start = std::time::Instant::now();
$b
println!("Elapsed: {:?}", start.elapsed());
};
}time_it!({
let mut sum = 0;
for i in 0..1000 { sum += i; }
println!("Sum: {}", sum);
});Matches a { ... } block. Very useful for wrapping arbitrary code.
tt — A Token Tree
macro_rules! echo {
($($t:tt)*) => {
println!("{}", stringify!($($t)*))
};
}echo!(hello world this is anything);The most flexible specifier. tt matches a single token or a grouped token tree (anything inside (), [], or {}). Using $($t:tt)* captures everything.
Use case: When you don’t know what the user will pass, or you want to forward tokens to another macro.
pat — A Pattern
macro_rules! match_value {
($val:expr, $p:pat => $body:expr) => {
match $val {
$p => $body,
_ => println!("no match"),
}
};
}
match_value!(Some(42), Some(x) => println!("Got {}", x));Matches a Rust pattern (like the left side of a match arm).
item — A Rust Item
macro_rules! export_item {
($item:item) => {
pub $item
};
}
export_item!(fn greet() { println!("hi"); });
// expands to: pub fn greet() { println!("hi"); }Matches a top-level Rust item: a function definition, struct, enum, trait, impl block, etc.
path — A Path
macro_rules! use_type {
($p:path) => {
let _: $p;
};
}
use_type!(std::collections::HashMap::<String, i32>);Matches a Rust path like std::io::Error, MyModule::MyType, etc.
meta — A Meta Item (Used in Attributes)
macro_rules! apply_attr {
(#[$m:meta] $item:item) => {
#[$m]
$item
};
}Matches the content inside an attribute like #[derive(Debug)] — the derive(Debug) part is a meta.
lifetime — A Lifetime
macro_rules! with_lifetime {
($lt:lifetime, $t:ty) => {
&$lt $t
};
}Matches a Rust lifetime like 'a, 'static, '_.
vis — A Visibility Modifier
macro_rules! make_field {
($v:vis $name:ident: $t:ty) => {
$v $name: $t
};
}Matches visibility keywords: pub, pub(crate), pub(super), or nothing (private).
literal — A Literal Value
macro_rules! announce {
($lit:literal) => {
println!("The value is: {}", $lit);
};
}
announce!(42);
announce!("hello");
announce!(3.14);Matches literal values only: numbers, strings, booleans, characters. Unlike expr, it won't match expressions like 1 + 2.
6. Macros with Parameters
Now that you understand fragment specifiers, let’s look at how to capture and use parameters in macros.
Basic Parameter Capture
macro_rules! square {
($x:expr) => {
$x * $x
};
}
let result = square!(5); // 25
let result2 = square!(2 + 3); // (2 + 3) * (2 + 3) = 25The $x is a metavariable (sometimes called a macro variable). The $ prefix distinguishes it from regular Rust variables. You can reuse $x as many times as you want in the expansion.
Multiple Parameters
macro_rules! max {
($a:expr, $b:expr) => {
if $a > $b { $a } else { $b }
};
}
let m = max!(10, 20); // 20
let m2 = max!(3 * 4, 2 + 9); // 12 vs 11, so 12Parameters with Different Types
macro_rules! create_function {
($func_name:ident) => {
fn $func_name() {
println!("Function {:?} called", stringify!($func_name));
}
};
}
create_function!(foo);
create_function!(bar);
fn main() {
foo(); // Function "foo" called
bar(); // Function "bar" called
}Here, $func_name:ident captures an identifier, which is then used as a function name in the expansion. This is genuinely code generating code — we defined two functions by calling the macro twice.
7. Multiple Pattern Arms in Detail
Macros support multiple arms, which lets them behave differently based on what they receive — similar to function overloading in other languages.
macro_rules! describe {
() => {
println!("Nothing was passed.");
};
($x:expr) => {
println!("Got one thing: {:?}", $x);
};
($x:expr, $y:expr) => {
println!("Got two things: {:?} and {:?}", $x, $y);
};
($x:expr, $y:expr, $z:expr) => {
println!("Got three things: {:?}, {:?}, and {:?}", $x, $y, $z);
};
}describe!(); // Nothing was passed.
describe!(42); // Got one thing: 42
describe!(1, 2); // Got two things: 1 and 2
describe!(1, 2, 3); // Got three things: 1, 2, and 3Rust checks arms in order, from top to bottom, and uses the first match. This means you should generally put more specific patterns before more general ones.
Comparing to match
This multi-arm structure mirrors Rust’s match expression:
// Rust match
match value {
0 => println!("zero"),
1 => println!("one"),
_ => println!("other"),
}// Macro with multiple arms
macro_rules! my_macro {
(zero) => { println!("zero") };
(one) => { println!("one") };
($x:expr) => { println!("other: {}", $x) };
}Both are pattern-based. The key difference is that match runs at runtime and matches against values; macro_rules! arms are checked at compile time and match against token patterns.
8. Repetition and Variadic Patterns
This is where macros get truly powerful — and where beginners sometimes get lost. Take your time with this section.
The magic of println! accepting any number of arguments, or vec! accepting any number of elements, comes from repetition patterns.
The Three Repetition Operators
Operator Meaning * Zero or more + One or more ? Zero or one (optional)
These look just like regex quantifiers, and they work similarly.
Zero or More: *
macro_rules! sum {
($($x:expr),*) => {
{
let mut total = 0;
$(total += $x;)*
total
}
};
}
let s0 = sum!(); // 0
let s1 = sum!(5); // 5
let s2 = sum!(1, 2, 3); // 6
let s3 = sum!(10, 20, 30, 40); // 100Let’s break down ($($x:expr),*):
( ← outer delimiter for this arm's pattern
$( ← start of repetition group
$x:expr ← capture one expression as $x
), ← the separator between repetitions (a comma)
* ← repeat zero or more times
)And in the expansion, $(total += $x;)* means "repeat the statement total += $x; once for each captured $x."
One or More: +
macro_rules! first {
($head:expr $(, $tail:expr)+) => {
$head
};
}
// Must have at least 2 elements
let f = first!(1, 2, 3); // 1
// first!(); ← compile error! + requires at least one occurrenceUse + when you need at least one element. Using * would allow empty input.
Optional: ?
macro_rules! greet {
($name:expr $(, $greeting:expr)?) => {
match (Some($name), ${count($greeting)}) {
_ => {
println!("Hello, {}!", $name);
}
}
};
}The ? operator is a bit more subtle. It's often used for optional trailing syntax or optional configuration parameters.
A cleaner example:
macro_rules! config {
($key:ident $(= $val:expr)?) => {
// $val is either present or not
};
}
config!(debug); // no value
config!(timeout = 30); // with valueThe Separator
In $($x:expr),*, the comma between ) and * is the separator. It appears between repetitions but not after the last one.
So sum!(1, 2, 3) matches correctly: 1, then , 2, then , 3. The comma is the separator, not part of the expression itself.
What about trailing commas? sum!(1, 2, 3,) — that last comma would fail! The separator appears between elements, not after the last one.
To handle trailing commas, you can use a common trick:
macro_rules! sum {
($($x:expr),* $(,)?) => {
{
let mut total = 0;
$(total += $x;)*
total
}
};
}
let s = sum!(1, 2, 3,); // Now this works!The $(,)? at the end matches an optional trailing comma.
9. Separator Handling and Edge Cases
Edge cases are where most beginner macro writers run into trouble. Let’s go through them systematically.
The Trailing Comma Problem
macro_rules! sum_basic {
($($x:expr),*) => { /* ... */ };
}
sum_basic!(1, 2, 3); // ✅ works
sum_basic!(1, 2, 3,); // ❌ compile error: unexpected token ','The trailing comma is one of the most common issues. Fix it with $(,)?:
macro_rules! sum_fixed {
($($x:expr),* $(,)?) => { /* ... */ };
}
sum_fixed!(1, 2, 3); // ✅ works
sum_fixed!(1, 2, 3,); // ✅ also works nowThis pattern is so common that it’s essentially considered best practice for any variadic macro.
The Empty Invocation
sum!() // uses * → expands to total = 0; returns 0 ✅
sum!() // uses + → compile error! + needs at least one ❌Choose * if empty is valid, + if you need at least one.
Nested Repetition
You can have repetition inside repetition, which is powerful but tricky:
macro_rules! matrix {
($([$($x:expr),*]),*) => {
vec![$(vec![$($x),*]),*]
};
}
let m = matrix!([1, 2, 3], [4, 5, 6], [7, 8, 9]);
// creates: vec![vec![1,2,3], vec![4,5,6], vec![7,8,9]]The outer $( ... ),* iterates over rows. The inner $( $x:expr ),* iterates over elements within each row.
Important rule: In nested repetition, you can only use a variable in the repetition depth where it was captured. You can’t mix inner and outer variables carelessly.
Ambiguity in Patterns
Rust’s macro matcher can sometimes become ambiguous. For example, if you have an arm that matches $x:expr and another that matches $x:expr, $y:expr, the matcher needs to know when to stop. Rust handles this with a concept called FOLLOW sets — certain tokens can follow certain fragment types.
For instance, after $x:expr, only certain tokens are allowed (like ,, ;, =>, |, etc.). If you try:
macro_rules! bad {
($x:expr $y:expr) => { /* ... */ }; // ❌ ambiguous!
}The compiler will complain because there’s no separator between $x and $y, making it impossible to know where one ends and the other begins.
10. Nested Macros: Macros Calling Macros
Macros can call other macros — both standard ones like println! and custom ones you define.
macro_rules! log {
($msg:expr) => {
println!("[LOG] {}", $msg);
};
}macro_rules! debug_value {
($name:ident, $val:expr) => {
log!(format!("{} = {:?}", stringify!($name), $val));
};
}fn main() {
let x = 42;
debug_value!(x, x); // [LOG] x = 42
}
Here, debug_value! expands and the expansion itself contains a call to log!, which then gets expanded further. The expansion happens in rounds: Rust keeps expanding until no more macros remain.
Expansion Order
Macros are expanded from the outside in. If a! expands to b!(), then b! is expanded next. This continues until all macros are resolved.
The stringify! Macro
Notice stringify!($name) in the example. This is a built-in macro that converts its argument to a string literal at compile time. So stringify!(x) becomes "x". It's incredibly useful inside other macros.
Readability Warning
Deeply nested macros quickly become hard to read and debug. If you find yourself with three or four levels of nesting, consider whether some of the inner logic should be a function instead.
11. Scope and Visibility
Unlike functions and types, macros have their own scoping rules that confuse many beginners.
Local Scope
By default, a macro is available from the point of definition to the end of the current scope:
fn main() {
macro_rules! local_macro {
() => { println!("local!") };
}
local_macro!(); // ✅ works here
{
local_macro!(); // ✅ works in inner scope too
}
}
// local_macro!(); // ❌ can't use here - out of scopeModule Scope
Macros defined at the module level are not automatically available in other modules, even child modules:
mod my_module {
macro_rules! inner_macro {
() => { println!("inner") };
}
pub fn use_it() {
inner_macro!(); // ✅ works here
}
}fn main() {
// inner_macro!(); // ❌ not accessible here
my_module::use_it(); // ✅ works
}#[macro_export]
To make a macro available throughout your crate (and to users of your crate), mark it with #[macro_export]:
#[macro_export]
macro_rules! my_public_macro {
() => { println!("exported!") };
}When #[macro_export] is used, the macro is placed at the crate root, not in the module where it was defined. This is an important detail — even if you define it inside my_module, it's exported as if it were at the crate root.
Using Macros from Other Crates
If you depend on a crate that exports macros, you can use them with:
use some_crate::some_macro;
// or to get all macros:
use some_crate::*;Since Rust 2018 edition, you no longer need #[macro_use] on external crates for most purposes (though it still exists for legacy compatibility).
12. Macro Hygiene: The Invisible Superpower
Hygiene is one of Rust’s most underrated macro features. It’s what prevents your macros from accidentally breaking the code around them.
The Problem It Solves
Imagine a macro that creates a temporary variable named result. Now imagine someone uses that macro inside a function that already has a variable called result. In many languages, the macro would accidentally shadow or corrupt the outer result.
Let’s see what could go wrong without hygiene:
// Hypothetically, without hygiene:
let result = 100;
some_macro!(); // internally creates `let result = 42;`
println!("{}", result); // Would this print 100 or 42?This is the classic variable capture problem. It causes subtle, hard-to-debug bugs.
How Rust Handles It
Rust’s macro system is hygienic by default. Variables introduced inside a macro expansion exist in their own distinct scope — they cannot accidentally collide with variables in the calling code.
macro_rules! add_one {
($x:expr) => {{
let result = $x + 1; // This `result` is private to the macro
result
}};
}let result = 100; // This `result` belongs to the outer code
let y = add_one!(5); // The macro's `result` is different
println!("{}", result); // Still prints 100 ← hygiene!The two result variables live in different "universes" as far as the compiler is concerned. They have the same text name, but they're fundamentally separate bindings.
When You Want to Escape Hygiene
Sometimes you want a macro to define a variable that the caller can use. This requires using $name:ident patterns where the caller provides the name:
macro_rules! assign_double {
($name:ident, $val:expr) => {
let $name = $val * 2;
};
}
assign_double!(my_result, 21);
println!("{}", my_result); // 42By using $name:ident and having the caller pass my_result, you're letting the calling code name the variable. This is intentional and hygienic — the caller chose the name.
13. Built-in Standard Macros
Before writing your own, it’s worth understanding the macros you use every day. They’re all built with macro_rules! (or procedural macros) and follow the same rules.
println! and print!
println!("Hello");
println!("Hello, {}!", "world");
println!("{:?}", vec![1, 2, 3]);
println!("{name} is {age}", name = "Alice", age = 30);println! is a variadic macro. It takes a format string plus any number of arguments. Behind the scenes, it uses Rust's formatting infrastructure (std::fmt) to generate a string and write it to stdout.
The first argument must be a string literal (not a variable) because the macro inspects it at compile time to generate the right formatting code.
format!
let s = format!("Hello, {}! You are {} years old.", "Bob", 25);
// s is now a StringSame as println! but returns a String instead of printing it. Extremely common in real-world Rust.
vec!
let v1: Vec<i32> = vec![]; // empty
let v2 = vec![1, 2, 3, 4, 5]; // initialized with values
let v3 = vec![0; 10]; // ten zerosThe vec! macro has multiple arms. The second form with ; uses repetition to initialize all elements with the same value — something you couldn't express as a simple function.
assert! and assert_eq!
assert!(2 + 2 == 4);
assert!(2 + 2 == 4, "Math is broken!");assert_eq!(2 + 2, 4);
assert_eq!(2 + 2, 4, "Expected {} but got {}", 4, 2 + 2);assert! is special because when it fails, it shows you the code you wrote, not just the values. That's only possible at compile time — a regular function couldn't inspect its own source.
assert_eq! goes further: when it fails, it formats both values so you see:
thread 'main' panicked at 'assertion `left == right` failed
left: 5
right: 4'panic!
panic!("Something went terribly wrong");
panic!("Expected {}, got {}", expected, actual);Immediately terminates the current thread with a message. The macro version is needed because it supports formatting — you can include dynamic values in the panic message.
dbg!
let x = 5;
let y = dbg!(x * 2) + 1; // prints: [src/main.rs:2] x * 2 = 10
// y is now 11A beginner’s best friend. dbg! prints the source expression and its value to stderr, then returns the value. Unlike println!, dbg! shows you the file and line number automatically. It's non-destructive — you can wrap any expression with it without changing the logic.
14. Real-World Practical Use Cases
Let’s look at some real patterns you’ll encounter in production Rust code.
Logging System
macro_rules! log {
(ERROR, $($msg:tt)*) => {
eprintln!("[ERROR] {}", format!($($msg)*));
};
(WARN, $($msg:tt)*) => {
eprintln!("[WARN] {}", format!($($msg)*));
};
(INFO, $($msg:tt)*) => {
println!("[INFO] {}", format!($($msg)*));
};
(DEBUG, $($msg:tt)*) => {
if cfg!(debug_assertions) {
println!("[DEBUG] {}", format!($($msg)*));
}
};
}log!(INFO, "Server started on port {}", 8080);
log!(ERROR, "Failed to connect: {}", error_message);
log!(DEBUG, "Processing item #{}", item_id);This gives you a flexible, level-aware logging system in about 15 lines.
Custom Assertions for Testing
macro_rules! assert_between {
($val:expr, $low:expr, $high:expr) => {
assert!(
$val >= $low && $val <= $high,
"Expected {} to be between {} and {}, but it wasn't",
$val, $low, $high
);
};
}#[test]
fn test_temperature() {
let temp = compute_temperature();
assert_between!(temp, -10.0, 40.0);
}Builder-Style API
macro_rules! request {
($method:ident $url:expr) => {
Request::new(Method::$method, $url)
};
($method:ident $url:expr, body = $body:expr) => {
Request::new(Method::$method, $url).with_body($body)
};
($method:ident $url:expr, headers = { $($key:expr => $val:expr),* }) => {
{
let mut req = Request::new(Method::$method, $url);
$(req.add_header($key, $val);)*
req
}
};
}let r1 = request!(GET "/api/users");
let r2 = request!(POST "/api/users", body = json_data);
let r3 = request!(GET "/api/users", headers = { "Auth" => "Bearer token", "Accept" => "application/json" });Testing Helpers
macro_rules! test_case {
($name:ident: $input:expr => $expected:expr) => {
#[test]
fn $name() {
assert_eq!(process($input), $expected);
}
};
}test_case!(empty_input: "" => None);
test_case!(single_item: "hello" => Some("hello".to_string()));
test_case!(with_spaces: " hi " => Some("hi".to_string()));This generates three separate test functions with clean names — no copy-paste required.
15. DSLs Using Macros
One of the most exciting applications of macros is building Domain-Specific Languages (DSLs) — mini-languages tailored to a specific problem.
A DSL is not a full programming language. It’s a set of readable, declarative syntax forms that make expressing solutions in a particular domain feel natural.
Why Macros Are Perfect for DSLs
Functions constrain you to Rust’s function-call syntax. Macros let you define your own syntax forms. This means you can create APIs that read almost like prose.
A Routing DSL
macro_rules! route {
($path:literal => $handler:expr) => {
Router::add(Method::GET, $path, $handler)
};
($method:ident $path:literal => $handler:expr) => {
Router::add(Method::$method, $path, $handler)
};
}route!("/home" => home_handler);
route!(POST "/login" => login_handler);
route!(DELETE "/users/:id" => delete_user_handler);An HTML Template DSL
macro_rules! html {
(head { title: $title:literal }) => {
format!("<head><title>{}</title></head>", $title)
};
(p { $content:expr }) => {
format!("<p>{}</p>", $content)
};
(div { $($child:expr)* }) => {
format!("<div>{}</div>", vec![$($child),*].join(""))
};
}let page = html!(div {
html!(p { "Hello world" })
html!(p { "This is Rust-generated HTML" })
});An Event System DSL
macro_rules! on_event {
($event:ident => $handler:block) => {
EventBus::subscribe(stringify!($event), |_| $handler);
};
($event:ident ($data:ident: $ty:ty) => $handler:block) => {
EventBus::subscribe(stringify!($event), |raw| {
let $data: $ty = raw.downcast().unwrap();
$handler
});
};
}on_event!(user_joined => {
println!("Someone joined!");
});on_event!(message_received(msg: String) => {
println!("Got message: {}", msg);
});
DSL macros make Rust code read more like specifications than implementations. They’re a key reason why Rust frameworks can feel elegant despite Rust’s verbosity in raw form.
16. Procedural Macros: The Next Level
Everything we’ve covered so far has been declarative macros (macro_rules!). There's a more powerful type called procedural macros that work differently.
Instead of pattern-matching token trees, procedural macros are full Rust programs that:
- Accept a stream of tokens as input
- Process those tokens using Rust code
- Return a new stream of tokens as output
There are three kinds:
1. Derive Macros
These are attached to structs and enums to automatically implement traits.
#[derive(Debug, Clone, PartialEq)]
struct Point {
x: f64,
y: f64,
}The #[derive(Debug)] attribute triggers a procedural macro that reads your struct's definition and generates a Debug implementation for it — something that would require many lines of boilerplate by hand.
You can write your own derive macros. The popular serde crate uses this to auto-generate serialization/deserialization code:
#[derive(Serialize, Deserialize)]
struct Config {
host: String,
port: u16,
}With just one line, serde generates hundreds of lines of custom serialization code.
2. Attribute Macros
These can be placed on any item and transform it:
#[tokio::main]
async fn main() {
// async code here
}#[tokio::main] is an attribute macro that wraps your main function with Tokio's async runtime setup. Without it, you'd need to write boilerplate to initialize the runtime yourself.
Another common one:
#[route(GET, "/")]
async fn index(req: HttpRequest) -> impl Responder {
"Hello!"
}Frameworks like Actix-web use attribute macros to register route handlers cleanly.
3. Function-Like Procedural Macros
These look like macro_rules! calls but are backed by Rust code:
let sql = sql!(SELECT * FROM users WHERE id = 1);A function-like procedural macro can parse the SQL string at compile time, validate it, and generate optimized Rust code — catching SQL errors before your program even runs.
When to Use Procedural vs. Declarative
Use macro_rules! when:
- Your pattern is straightforward
- You don’t need to analyze tokens beyond matching
- You want something simple and self-contained
Use procedural macros when:
- You need complex token analysis
- You’re generating code based on struct fields or variants
- You want to implement derive traits
- You need compiler error messages with precise spans
17. Common Beginner Mistakes
Learning from mistakes saves time. Here are the ones that trip up almost every new Rust macro writer.
Mistake 1: Using a Macro When a Function Would Do
Macros add complexity. If your macro doesn’t need variadic arguments, custom syntax, or compile-time code generation, write a function instead.
// ❌ Don't do this
macro_rules! add_numbers {
($a:expr, $b:expr) => { $a + $b };
}
// ✅ Just write this
fn add_numbers(a: i32, b: i32) -> i32 { a + b }Mistake 2: Wrong Fragment Specifier
// ❌ Wrong: using expr when you need ident
macro_rules! define_fn {
($name:expr) => {
fn $name() {} // ← error! expr can't be used as a function name
};
}// ✅ Correct: use ident
macro_rules! define_fn {
($name:ident) => {
fn $name() {}
};
}When you want a name, use ident. When you want a value, use expr. Mixing these up is the most common fragment specifier mistake.
Mistake 3: Forgetting the Trailing Comma Pattern
Not including $(,)? means your macro will fail for users who habitually add trailing commas:
// ❌ Will reject: my_macro!(a, b, c,)
macro_rules! my_macro {
($($x:expr),*) => { /* ... */ };
}// ✅ Accept trailing commas gracefully
macro_rules! my_macro {
($($x:expr),* $(,)?) => { /* ... */ };
}Mistake 4: Unexpected Expansion Behavior with Expressions
macro_rules! double_call {
($x:expr) => {
$x + $x // ← $x is expanded TWICE
};
}
let mut n = 0;
let result = double_call!({ n += 1; n }); // n is incremented TWICE!
// result is 1 + 2 = 3, not 2!If $x is a side-effectful expression, expanding it multiple times causes the side effects to run multiple times. Fix this by storing in a temporary:
macro_rules! double_call {
($x:expr) => {{
let val = $x; // evaluate once
val + val
}};
}Mistake 5: Infinite Recursion
// ❌ This will cause a compile error (recursion limit reached)
macro_rules! forever {
() => { forever!() };
}Macros can call themselves recursively (useful for processing lists), but you must always have a base case that stops the recursion.
// ✅ Recursive macro with base case
macro_rules! count_items {
() => { 0 }; // base case
($first:expr $(, $rest:expr)*) => {
1 + count_items!($($rest),*) // recursive case
};
}
let c = count_items!(a, b, c, d); // 4Mistake 6: Confusing Macro Scope
Macros defined in a child module are not visible in the parent module unless exported. Beginners often define a macro in a module and wonder why it’s not accessible elsewhere.
18. Best Practices
When to Use Macros
- Variadic arguments (different number of inputs)
- Generating repetitive code patterns
- DSL-style readable syntax
- Compile-time validation or code generation
- Test helper infrastructure
When Not to Use Macros
- Logic that could be a regular function
- Simple computations
- Situations where you want clear stack traces
- Code others need to easily read and understand
- Anything that can be done with generics or traits
Naming Conventions
- Use
snake_casefor macro names:my_macro!, notMyMacro! - Make the name clearly describe what the macro does
- If a macro generates a type or function, the name should hint at that
Documentation
Always document macros, especially if they’re public. The /// doc comment works on macros just like on functions:
/// Creates a HashMap from key-value pairs.
///
/// # Example
/// ```
/// let map = map!{ "a" => 1, "b" => 2 };
/// assert_eq!(map["a"], 1);
/// ```
#[macro_export]
macro_rules! map {
($($key:expr => $val:expr),* $(,)?) => {
{
let mut m = std::collections::HashMap::new();
$(m.insert($key, $val);)*
m
}
};
}Testing Macros
You can test macros just like functions using #[test]:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sum_macro_empty() {
assert_eq!(sum!(), 0);
}
#[test]
fn test_sum_macro_multiple() {
assert_eq!(sum!(1, 2, 3, 4, 5), 15);
}
#[test]
fn test_sum_trailing_comma() {
assert_eq!(sum!(1, 2, 3,), 6);
}
}Debugging with cargo expand
The most useful tool for debugging macros is cargo-expand. Install it with:
cargo install cargo-expandThen run:
cargo expandThis shows you your entire source file after all macros have been expanded. When a macro isn’t behaving as expected, expanding it is the fastest way to see what’s actually being generated.
What to Practice Next
- Rebuild
vec!from scratch. Write a macro that takes comma-separated expressions and creates aVec. Add support for trailing commas and the[value; count]form. - Write a
hashmap!macro. Acceptkey => valuepairs and produce aHashMap. - Create a
test_cases!macro. Generate multiple test functions from a compact table of inputs and expected outputs. - Build a simple logging macro. Support log levels (INFO, WARN, ERROR) and format strings.
- Explore
cargo expand. Take any macro from the standard library, put it in your code, and expand it to see what it generates.
Suggested Mini Projects
- A
retry!macro that retries a block of code N times on failure - A
timed!macro that measures and prints the execution time of any block - A simple query builder DSL using macros
- An
impl_trait_for_all!macro that implements a simple trait for multiple types at once
Recommended Next Topics
Once you’re comfortable with declarative macros, the natural next step is procedural macros — writing derive macros, building your own #[derive(MyTrait)]. The syn and quote crates are the tools of choice.
From there, explore how major Rust libraries use macros in practice:
serde— for automatic serializationtokio— for async runtime setupdiesel— for type-safe SQL queries
Rust macros are one of those features that, once you understand them, you start seeing opportunities everywhere. They’re the reason Rust can be both low-level and expressive — the kind of language where you can define your own ergonomic abstractions without paying runtime costs.
The ! was never just punctuation. It was always an invitation.