M Logo
๐Ÿ“ Rust Backend with Axum ยท Part 38 of 42

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.

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:

rust
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 more

A 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:

rust
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.

bash
cargo run --example macro_demo
text
9

Nine. 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:

rust
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.

rust
let versions = hashmap!("rust" => 1, "axum" => 2);
println!("{:?}", versions.get("axum"));
text
Some(2)

With a typo, a colon instead of the arrow ("rust": 1):

text
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,)?

text
error: unexpected end of macro invocation

The pattern expects another pair after every comma. We allow a trailing comma with $(,)?, an optional comma:

rust
($($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:

rust
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 an ident and a ty, 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:

rust
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:

bash
RUSTC_BOOTSTRAP=1 cargo rustc --example macro_demo -- -Zunpretty=expanded | tail -22

Our 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.)

bash
cargo test

Still 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

rust
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

rust
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 are pattern => code, expanded at compile time
  • Fragments: expr (keeps its grouping, so square!(2 + 1) is 9), ident, ty
  • $( ... ),* repeats; $(,)? allows a trailing comma
  • "no rules expected the token" points to where the pattern failed
  • -Zunpretty=expanded (or cargo 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.

ยฉ 2026 All Rights Reserved By Mahmudul Alam