Rust Web Server with Axum 0.8: Declarative Macros with macro_rules!
November 3, 2026
Episode 38 of the Rust Backend series, where we build the backend of a blog step by step. After episode 37 our blog API is in production. Part 6 goes deeper into Rust itself. We have used macros since episode 1: println!, json!, query_as!. Today we write our own, with macro_rules!. All the code below is the final, tested code from the video, ready to copy.
- New to Rust? Start with the beginner series.
- The full backend series playlist is here.
We continue in the same blog_api project from episode 37. The small examples live in examples/macro_demo.rs and run with cargo run --example macro_demo, so the blog code stays clean.
1. Why a macro?
Our tests have a FailingRepo in src/failing.rs: a repository whose database is always down. It has nineteen methods, each with the same body:
fn get(&self, id: i64) -> impl Future<Output = sqlx::Result<Option<Post>>> + Send {
let _ = id;
async { Err(sqlx::Error::PoolTimedOut) }
}
fn create(&self, author_id: i64, new: NewPost) -> impl Future<Output = sqlx::Result<PostWithTags>> + Send {
let _ = author_id;
let _ = new;
async { Err(sqlx::Error::PoolTimedOut) }
}
// ... seventeen moreA function can't help here, because the methods have different names and types. A macro writes code, so it can.
2. Pattern => code
A macro_rules! macro is a list of rules: pattern => code. When the input matches the pattern, the code is pasted in its place, at compile time. A first small one:
macro_rules! square {
($x:expr) => {
$x * $x
};
}
fn main() {
println!("{}", square!(2 + 1));
}$x:expr captures one expression and names it $x. What does square!(2 + 1) print? If the macro pasted text, it would be 2 + 1 * 2 + 1, which is 5.
cargo run --example macro_demo9Nine. An expr fragment keeps its grouping, so this is (2 + 1) * (2 + 1). Rust macros work on syntax trees, not on text like the C preprocessor.
3. Repetition
$( ... ),* means: this part may come zero or more times, separated by commas. hashmap! takes key => value pairs:
macro_rules! hashmap {
($($key:expr => $value:expr),*) => {{
let mut map = ::std::collections::HashMap::new();
$(map.insert($key, $value);)*
map
}};
}In the code part, $( ... )* repeats once for each match. The double braces make the macro one block expression, so it can be used as a value. ::std::... with leading colons always finds the real std, wherever the macro is used.
let versions = hashmap!("rust" => 1, "axum" => 2);
println!("{:?}", versions.get("axum"));Some(2)With a typo, a colon instead of the arrow ("rust": 1):
error: no rules expected the token `:`The macro tells us exactly where its pattern did not match. And a comma after the last pair, hashmap!("rust" => 1, "axum" => 2,)?
error: unexpected end of macro invocationThe pattern expects another pair after every comma. We allow a trailing comma with $(,)?, an optional comma:
($($key:expr => $value:expr),* $(,)?) => {{4. The real one: failing_methods
Now the macro for FailingRepo. It takes a list of method signatures, and writes each one with the same body:
macro_rules! failing_methods {
($(fn $name:ident(&self $(, $arg:ident: $arg_ty:ty)*) -> $ret:ty;)*) => {
$(
fn $name(&self $(, $arg: $arg_ty)*) -> impl std::future::Future<Output = sqlx::Result<$ret>> + Send {
$(let _ = $arg;)*
async { Err(sqlx::Error::PoolTimedOut) }
}
)*
};
}- Each method has a name (
$name:ident), parameters (each anidentand aty, a type), and a return type ($ret:ty). - The outer repetition writes one function per signature; the inner one handles the parameters.
let _ = $arg;uses every argument, so there are no "unused variable" warnings.
FailingRepo now lists only the signatures:
impl Repo for FailingRepo {
failing_methods! {
fn list(&self, f: ListFilter) -> (Vec<Post>, i64);
fn get(&self, id: i64) -> Option<Post>;
fn create(&self, author_id: i64, new: NewPost) -> PostWithTags;
fn update(&self, id: i64, new: NewPost) -> Option<Post>;
fn delete(&self, id: i64) -> bool;
// ... one line per method
fn add_image(&self, post_id: i64, url: &str) -> Image;
}
}From about a hundred lines down to twenty-five.
5. What did the macro write?
rustc can print the code after all macros ran. It is a nightly-only flag; RUSTC_BOOTSTRAP=1 allows it on stable for a quick look:
RUSTC_BOOTSTRAP=1 cargo rustc --example macro_demo -- -Zunpretty=expanded | tail -22Our hashmap! call, now plain Rust: a block with HashMap::new() and one insert per pair. (The cargo-expand tool does the same with nicer output.)
cargo testStill green.
6. Hygiene, and when to use macros
A variable made inside a macro (like map in hashmap!) cannot clash with a variable of the same name outside it. That is called hygiene.
When to write a macro? When functions and generics can't do it: repeating code with different names, a small syntax of your own, or counting arguments. Macros are harder to read and to debug, so they are the last tool, not the first.
The final code
examples/macro_demo.rs
macro_rules! square {
($x:expr) => {
$x * $x
};
}
macro_rules! hashmap {
($($key:expr => $value:expr),* $(,)?) => {{
let mut map = ::std::collections::HashMap::new();
$(map.insert($key, $value);)*
map
}};
}
fn main() {
println!("{}", square!(2 + 1));
let versions = hashmap!("rust" => 1, "axum" => 2,);
println!("{:?}", versions.get("axum"));
}src/failing.rs
use std::future::Future;
use crate::models::{Comment, Hit, Image, ListFilter, NewComment, NewPost, Post, PostWithTags, User, UserWithHash};
use crate::repo::Repo;
macro_rules! failing_methods {
($(fn $name:ident(&self $(, $arg:ident: $arg_ty:ty)*) -> $ret:ty;)*) => {
$(
fn $name(&self $(, $arg: $arg_ty)*) -> impl std::future::Future<Output = sqlx::Result<$ret>> + Send {
$(let _ = $arg;)*
async { Err(sqlx::Error::PoolTimedOut) }
}
)*
};
}
/// A repository whose database is always down: every method fails.
#[derive(Clone)]
pub struct FailingRepo;
impl Repo for FailingRepo {
failing_methods! {
fn list(&self, f: ListFilter) -> (Vec<Post>, i64);
fn get(&self, id: i64) -> Option<Post>;
fn create(&self, author_id: i64, new: NewPost) -> PostWithTags;
fn update(&self, id: i64, new: NewPost) -> Option<Post>;
fn delete(&self, id: i64) -> bool;
fn restore(&self, id: i64) -> bool;
fn list_comments(&self, post_id: i64) -> Vec<Comment>;
fn add_comment(&self, post_id: i64, new: NewComment) -> Comment;
fn search(&self, q: &str) -> Vec<Hit>;
fn create_user(&self, email: &str, password_hash: &str) -> User;
fn find_user_by_email(&self, email: &str) -> Option<UserWithHash>;
fn get_user(&self, id: i64) -> Option<User>;
fn store_refresh(&self, hash: &str, user_id: i64, expires_at: i64) -> ();
fn take_refresh(&self, hash: &str, now: i64) -> Option<i64>;
fn delete_refresh(&self, hash: &str) -> ();
fn delete_expired_refresh(&self, now: i64) -> u64;
fn list_users(&self) -> Vec<User>;
fn ping(&self) -> ();
fn add_image(&self, post_id: i64, url: &str) -> Image;
}
}Recap
macro_rules!rules arepattern => code, expanded at compile time- Fragments:
expr(keeps its grouping, sosquare!(2 + 1)is 9),ident,ty $( ... ),*repeats;$(,)?allows a trailing comma- "no rules expected the token" points to where the pattern failed
-Zunpretty=expanded(orcargo expand) shows the generated code- Macro variables are hygienic; use macros only when functions and generics can't
Next episode: the other kind of macro, a procedural macro. We write our own derive. Watch the series on YouTube.