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

Rust Web Server with Axum 0.8: Writing a Procedural Macro, Our Own Derive

November 3, 2026

Episode 39 of the Rust Backend series, where we build the backend of a blog step by step. After episode 38 we can write macro_rules! macros. Serialize, Deserialize, Validate, ToSchema: all of these are derives somebody wrote. Today we write one: #[derive(Columns)]. 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 38. New today: a second crate, blog_macros, with syn and quote.

1. The problem

In our list query (episode 14), the column names are typed by hand:

rust
let mut q = QueryBuilder::<Sqlite>::new("SELECT p.id, p.title, p.body, p.slug, p.created_at, p.author_id FROM posts p WHERE p.deleted_at IS NULL");

Add a field to Post, forget the query, and it breaks at run time (QueryBuilder is not checked at compile time). But the struct already knows its fields. A derive can read them.

2. What a procedural macro is

A procedural macro is a Rust function that runs inside the compiler. It gets our struct as tokens, and returns new code as tokens. Unlike macro_rules!, it is normal Rust code, so it can do anything: loops, string work, error messages.

It must live in its own crate. A new crate, blog_macros, in a folder inside our project:

toml
[package]
name = "blog_macros"
version = "0.1.0"
edition = "2024"

The function, in blog_macros/src/lib.rs, empty for now:

rust
use proc_macro::TokenStream;

#[proc_macro_derive(Columns)]
pub fn derive_columns(input: TokenStream) -> TokenStream {
    let _ = input;
    TokenStream::new()
}

3. The crate type

bash
cd blog_macros && cargo build
text
error: the `#[proc_macro_derive]` attribute is only usable with crates of the `proc-macro` crate type

The crate must say so, and it needs two helpers: syn parses tokens into a syntax tree, and quote turns Rust-like code back into tokens:

toml
[lib]
proc-macro = true

[dependencies]
syn = "2"
quote = "1"

4. Parse, collect, generate

rust
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields};

#[proc_macro_derive(Columns)]
pub fn derive_columns(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;
    let columns: Vec<String> = match &input.data {
        Data::Struct(data) => match &data.fields {
            Fields::Named(named) => named.named.iter().map(|f| f.ident.as_ref().unwrap().to_string()).collect(),
            _ => panic!("not a struct"),
        },
        _ => panic!("not a struct"),
    };
    quote! {
        impl #name {
            pub fn columns() -> &'static [&'static str] {
                &[#(#columns),*]
            }
        }
    }
    .into()
}
  • parse_macro_input! gives a DeriveInput: the name of the type (ident), and its data.
  • If it is a struct with named fields, we collect each field's name as a string.
  • quote! writes the output. #name puts a value into the code; #(#columns),* repeats it with commas, like $( ),* in macro_rules!. Each String becomes a string literal.

For Post, it generates impl Post { pub fn columns() -> &'static [&'static str] { &["id", "title", ...] } }.

5. Use it in the blog

bash
cargo add blog_macros --path blog_macros
rust
use blog_macros::Columns;

#[derive(Serialize, Deserialize, Clone, sqlx::FromRow, ToSchema, Columns)]
pub struct Post {

and the query uses Post::columns(), joined with commas:

rust
let mut q = QueryBuilder::<Sqlite>::new(format!("SELECT {} FROM posts p WHERE p.deleted_at IS NULL", Post::columns().join(", ")));
bash
cargo test

Green. The query is built from the struct now.

6. Good errors instead of panics

What if someone puts it on an enum? examples/bad_derive.rs:

rust
use blog_macros::Columns;

#[derive(Columns)]
enum Sort {
    Newest,
    Oldest,
}

fn main() {}
bash
cargo build --example bad_derive
text
error: proc-macro derive panicked
  = help: message: not a struct

Not helpful: no location, no explanation. Instead, we return a syn::Error with the span of the input. The compiler underlines exactly the type, with our own message:

rust
_ => return syn::Error::new_spanned(name, "Columns only works on structs with named fields").to_compile_error().into(),

(in both _ arms). Now:

text
error: Columns only works on structs with named fields
 --> examples/bad_derive.rs:4:6
  |
4 | enum Sort {
  |      ^^^^

7. A helper attribute: prefix

Our query uses the table alias p. A helper attribute adds it: #[columns(prefix = "p")] gives p.id, p.title, and so on. The derive declares the attribute, and reads it with parse_nested_meta:

rust
#[proc_macro_derive(Columns, attributes(columns))]
pub fn derive_columns(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;
    let mut prefix = String::new();
    for attr in &input.attrs {
        if attr.path().is_ident("columns") {
            let result = attr.parse_nested_meta(|meta| {
                if meta.path.is_ident("prefix") {
                    prefix = format!("{}.", meta.value()?.parse::<LitStr>()?.value());
                    Ok(())
                } else {
                    Err(meta.error("expected `prefix = \"...\"`"))
                }
            });
            if let Err(err) = result {
                return err.to_compile_error().into();
            }
        }
    }
    // ... collect the field names as before, then:
    let columns: Vec<String> = columns.iter().map(|f| format!("{prefix}{f}")).collect();
    // ... quote! as before
}

Add LitStr to the syn import. On Post:

rust
#[derive(Serialize, Deserialize, Clone, sqlx::FromRow, ToSchema, Columns)]
#[columns(prefix = "p")]
pub struct Post {

Remove the bad example, and run the tests:

bash
rm examples/bad_derive.rs
cargo test

Green. The query string comes from the struct: add a field, and the query follows by itself.

The final code

blog_macros/Cargo.toml

toml
[package]
name = "blog_macros"
version = "0.1.0"
edition = "2024"

[lib]
proc-macro = true

[dependencies]
syn = "2"
quote = "1"

blog_macros/src/lib.rs

rust
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields, LitStr};

#[proc_macro_derive(Columns, attributes(columns))]
pub fn derive_columns(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;
    let mut prefix = String::new();
    for attr in &input.attrs {
        if attr.path().is_ident("columns") {
            let result = attr.parse_nested_meta(|meta| {
                if meta.path.is_ident("prefix") {
                    prefix = format!("{}.", meta.value()?.parse::<LitStr>()?.value());
                    Ok(())
                } else {
                    Err(meta.error("expected `prefix = \"...\"`"))
                }
            });
            if let Err(err) = result {
                return err.to_compile_error().into();
            }
        }
    }
    let columns: Vec<String> = match &input.data {
        Data::Struct(data) => match &data.fields {
            Fields::Named(named) => named.named.iter().map(|f| f.ident.as_ref().unwrap().to_string()).collect(),
            _ => return syn::Error::new_spanned(name, "Columns only works on structs with named fields").to_compile_error().into(),
        },
        _ => return syn::Error::new_spanned(name, "Columns only works on structs with named fields").to_compile_error().into(),
    };
    let columns: Vec<String> = columns.iter().map(|f| format!("{prefix}{f}")).collect();
    quote! {
        impl #name {
            pub fn columns() -> &'static [&'static str] {
                &[#(#columns),*]
            }
        }
    }
    .into()
}

src/models.rs

rust
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError};
use utoipa::ToSchema;
use blog_macros::Columns;

#[derive(Serialize, Deserialize, Clone, sqlx::FromRow, ToSchema, Columns)]
#[columns(prefix = "p")]
pub struct Post {
    pub id: i64,
    pub title: String,
    pub body: String,
    pub slug: String,
    pub author_id: Option<i64>,
    pub created_at: String,
}

#[derive(Deserialize, Validate, ToSchema)]
pub struct NewPost {
    #[validate(length(min = 1, max = 200, message = "1 to 200 characters"), custom(function = "not_blank"))]
    pub title: String,
    #[validate(length(min = 1, max = 20000, message = "must not be empty"))]
    pub body: String,
    #[serde(default)]
    #[validate(length(max = 5, message = "at most 5 tags"))]
    pub tags: Vec<String>,
}

#[derive(Serialize, Clone)]
pub struct Comment {
    pub id: i64,
    pub post_id: i64,
    pub body: String,
    pub created_at: String,
}

#[derive(Deserialize, Validate)]
pub struct NewComment {
    #[validate(length(min = 1, max = 2000, message = "1 to 2000 characters"), custom(function = "not_blank"))]
    pub body: String,
}

pub fn slugify(title: &str) -> String {
    let mut slug = String::new();
    for c in title.trim().to_lowercase().chars() {
        if c.is_alphanumeric() {
            slug.push(c);
        } else if !slug.ends_with('-') {
            slug.push('-');
        }
    }
    let slug = slug.trim_end_matches('-').to_string();
    if slug.is_empty() {
        return format!("post-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
    }
    slug
}

#[derive(Serialize, ToSchema)]
pub struct PostWithTags {
    #[serde(flatten)]
    pub post: Post,
    pub tags: Vec<String>,
}

#[derive(Deserialize)]
pub struct ListParams {
    pub page: Option<u32>,
    pub per_page: Option<u32>,
    pub tag: Option<String>,
    #[serde(default)]
    pub sort: Sort,
}

#[derive(Deserialize, Clone, Copy, Default)]
#[serde(rename_all = "lowercase")]
pub enum Sort {
    #[default]
    Newest,
    Oldest,
    Title,
}

#[derive(Serialize)]
pub struct Page<T> {
    pub items: Vec<T>,
    pub page: u32,
    pub per_page: u32,
    pub total: i64,
}

pub struct ListFilter {
    pub page: u32,
    pub per_page: u32,
    pub tag: Option<String>,
    pub sort: Sort,
}

#[derive(Serialize)]
pub struct Hit {
    pub id: i64,
    pub title: String,
    pub snippet: String,
}

#[derive(Deserialize)]
pub struct SearchParams {
    pub q: String,
}

pub fn not_blank(value: &str) -> Result<(), ValidationError> {
    if value.trim().is_empty() {
        return Err(ValidationError::new("blank").with_message("must not be blank".into()));
    }
    Ok(())
}

#[derive(Deserialize, Validate)]
pub struct NewUser {
    #[validate(email(message = "must be a valid email"))]
    pub email: String,
    #[validate(length(min = 10, max = 200, message = "10 to 200 characters"))]
    pub password: String,
}

#[derive(Serialize, Clone)]
pub struct User {
    pub id: i64,
    pub email: String,
    pub role: String,
    pub created_at: String,
}

pub struct UserWithHash {
    pub id: i64,
    pub email: String,
    pub password_hash: String,
    pub role: String,
}

#[derive(Serialize)]
pub struct Image {
    pub id: i64,
    pub post_id: i64,
    pub url: String,
}

#[derive(Serialize, ToSchema)]
pub struct PostV2 {
    pub id: i64,
    #[schema(example = "Hello")]
    pub title: String,
    /// The post's text in Markdown.
    pub content: String,
    pub slug: String,
    pub author_id: Option<i64>,
    pub created_at: String,
}

impl From<Post> for PostV2 {
    fn from(p: Post) -> PostV2 {
        PostV2 { id: p.id, title: p.title, content: p.body, slug: p.slug, author_id: p.author_id, created_at: p.created_at }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn slug_from_title() {
        assert_eq!(slugify("Hello, World"), "hello-world");
    }

    #[test]
    fn slug_never_empty() {
        assert!(!slugify("!!!").is_empty());
    }
}

src/repo.rs

rust
use std::future::Future;

use sqlx::{QueryBuilder, Sqlite, SqlitePool};

use crate::models::{slugify, Comment, Hit, Image, ListFilter, NewComment, NewPost, Post, PostWithTags, Sort, User, UserWithHash};

pub trait Repo: Clone + Send + Sync + 'static {
    fn list(&self, f: ListFilter) -> impl Future<Output = sqlx::Result<(Vec<Post>, i64)>> + Send;
    fn get(&self, id: i64) -> impl Future<Output = sqlx::Result<Option<Post>>> + Send;
    fn create(&self, author_id: i64, new: NewPost) -> impl Future<Output = sqlx::Result<PostWithTags>> + Send;
    fn update(&self, id: i64, new: NewPost) -> impl Future<Output = sqlx::Result<Option<Post>>> + Send;
    fn delete(&self, id: i64) -> impl Future<Output = sqlx::Result<bool>> + Send;
    fn restore(&self, id: i64) -> impl Future<Output = sqlx::Result<bool>> + Send;
    fn list_comments(&self, post_id: i64) -> impl Future<Output = sqlx::Result<Vec<Comment>>> + Send;
    fn add_comment(&self, post_id: i64, new: NewComment) -> impl Future<Output = sqlx::Result<Comment>> + Send;
    fn search(&self, q: &str) -> impl Future<Output = sqlx::Result<Vec<Hit>>> + Send;
    fn create_user(&self, email: &str, password_hash: &str) -> impl Future<Output = sqlx::Result<User>> + Send;
    fn find_user_by_email(&self, email: &str) -> impl Future<Output = sqlx::Result<Option<UserWithHash>>> + Send;
    fn get_user(&self, id: i64) -> impl Future<Output = sqlx::Result<Option<User>>> + Send;
    fn store_refresh(&self, hash: &str, user_id: i64, expires_at: i64) -> impl Future<Output = sqlx::Result<()>> + Send;
    fn take_refresh(&self, hash: &str, now: i64) -> impl Future<Output = sqlx::Result<Option<i64>>> + Send;
    fn delete_refresh(&self, hash: &str) -> impl Future<Output = sqlx::Result<()>> + Send;
    fn delete_expired_refresh(&self, now: i64) -> impl Future<Output = sqlx::Result<u64>> + Send;
    fn list_users(&self) -> impl Future<Output = sqlx::Result<Vec<User>>> + Send;
    fn ping(&self) -> impl Future<Output = sqlx::Result<()>> + Send;
    fn add_image(&self, post_id: i64, url: &str) -> impl Future<Output = sqlx::Result<Image>> + Send;
}

#[derive(Clone)]
pub struct SqliteRepo {
    pub pool: SqlitePool,
}

impl Repo for SqliteRepo {
    async fn list(&self, f: ListFilter) -> sqlx::Result<(Vec<Post>, i64)> {
        let mut q = QueryBuilder::<Sqlite>::new(format!("SELECT {} FROM posts p WHERE p.deleted_at IS NULL", Post::columns().join(", ")));
        if let Some(tag) = &f.tag {
            q.push(" AND p.id IN (SELECT pt.post_id FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE t.name = ")
                .push_bind(tag.clone())
                .push(")");
        }
        q.push(match f.sort {
            Sort::Newest => " ORDER BY p.id DESC",
            Sort::Oldest => " ORDER BY p.id ASC",
            Sort::Title => " ORDER BY p.title ASC",
        });
        q.push(" LIMIT ").push_bind(f.per_page as i64);
        q.push(" OFFSET ").push_bind(((f.page - 1) * f.per_page) as i64);
        let posts = q.build_query_as::<Post>().fetch_all(&self.pool).await?;

        let mut c = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM posts p WHERE p.deleted_at IS NULL");
        if let Some(tag) = &f.tag {
            c.push(" AND p.id IN (SELECT pt.post_id FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE t.name = ")
                .push_bind(tag.clone())
                .push(")");
        }
        let total: i64 = c.build_query_scalar().fetch_one(&self.pool).await?;
        Ok((posts, total))
    }

    async fn search(&self, q: &str) -> sqlx::Result<Vec<Hit>> {
        let q = fts_query(q);
        sqlx::query_as!(
            Hit,
            r#"SELECT p.id as "id!", p.title, snippet(posts_fts, 1, '[', ']', '...', 8) as "snippet!: String"
               FROM posts_fts JOIN posts p ON p.id = posts_fts.rowid
               WHERE posts_fts MATCH ? AND p.deleted_at IS NULL
               ORDER BY bm25(posts_fts) LIMIT 20"#,
            q
        )
        .fetch_all(&self.pool)
        .await
    }

    async fn get(&self, id: i64) -> sqlx::Result<Option<Post>> {
        sqlx::query_as!(Post, "SELECT id, title, body, slug, created_at, author_id FROM posts WHERE id = ? AND deleted_at IS NULL", id)
            .fetch_optional(&self.pool)
            .await
    }

    async fn create(&self, author_id: i64, new: NewPost) -> sqlx::Result<PostWithTags> {
        let slug = slugify(&new.title);
        let mut tx = self.pool.begin().await?;
        let post = sqlx::query_as!(
            Post,
            r#"INSERT INTO posts (title, body, slug, author_id) VALUES (?, ?, ?, ?) RETURNING id, title, body, slug, created_at, author_id"#,
            new.title,
            new.body,
            slug,
            author_id
        )
        .fetch_one(&mut *tx)
        .await?;
        for tag in &new.tags {
            sqlx::query!("INSERT INTO tags (name) VALUES (?) ON CONFLICT (name) DO NOTHING", tag)
                .execute(&mut *tx)
                .await?;
            sqlx::query!(
                "INSERT INTO post_tags (post_id, tag_id) SELECT ?, id FROM tags WHERE name = ?",
                post.id,
                tag
            )
            .execute(&mut *tx)
            .await?;
        }
        tx.commit().await?;
        Ok(PostWithTags { post, tags: new.tags })
    }

    async fn update(&self, id: i64, new: NewPost) -> sqlx::Result<Option<Post>> {
        sqlx::query_as!(
            Post,
            r#"UPDATE posts SET title = ?, body = ? WHERE id = ? AND deleted_at IS NULL RETURNING id, title, body, slug, created_at, author_id"#,
            new.title,
            new.body,
            id
        )
        .fetch_optional(&self.pool)
        .await
    }

    async fn delete(&self, id: i64) -> sqlx::Result<bool> {
        let result = sqlx::query!("UPDATE posts SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL", id)
            .execute(&self.pool)
            .await?;
        Ok(result.rows_affected() > 0)
    }

    async fn restore(&self, id: i64) -> sqlx::Result<bool> {
        let result = sqlx::query!("UPDATE posts SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL", id)
            .execute(&self.pool)
            .await?;
        Ok(result.rows_affected() > 0)
    }

    async fn create_user(&self, email: &str, password_hash: &str) -> sqlx::Result<User> {
        sqlx::query_as!(
            User,
            r#"INSERT INTO users (email, password_hash) VALUES (?, ?) RETURNING id, email, role, created_at"#,
            email,
            password_hash
        )
        .fetch_one(&self.pool)
        .await
    }

    async fn find_user_by_email(&self, email: &str) -> sqlx::Result<Option<UserWithHash>> {
        sqlx::query_as!(
            UserWithHash,
            "SELECT id, email, password_hash, role FROM users WHERE email = ?",
            email
        )
        .fetch_optional(&self.pool)
        .await
    }

    async fn get_user(&self, id: i64) -> sqlx::Result<Option<User>> {
        sqlx::query_as!(User, "SELECT id, email, role, created_at FROM users WHERE id = ?", id)
            .fetch_optional(&self.pool)
            .await
    }

    async fn store_refresh(&self, hash: &str, user_id: i64, expires_at: i64) -> sqlx::Result<()> {
        sqlx::query!(
            "INSERT INTO refresh_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
            hash,
            user_id,
            expires_at
        )
        .execute(&self.pool)
        .await?;
        Ok(())
    }

    async fn take_refresh(&self, hash: &str, now: i64) -> sqlx::Result<Option<i64>> {
        sqlx::query_scalar!(
            r#"DELETE FROM refresh_tokens WHERE token_hash = ? AND expires_at > ? RETURNING user_id as "user_id!""#,
            hash,
            now
        )
        .fetch_optional(&self.pool)
        .await
    }

    async fn delete_refresh(&self, hash: &str) -> sqlx::Result<()> {
        sqlx::query!("DELETE FROM refresh_tokens WHERE token_hash = ?", hash).execute(&self.pool).await?;
        Ok(())
    }

    async fn delete_expired_refresh(&self, now: i64) -> sqlx::Result<u64> {
        let done = sqlx::query!("DELETE FROM refresh_tokens WHERE expires_at < ?", now).execute(&self.pool).await?;
        Ok(done.rows_affected())
    }

    async fn list_comments(&self, post_id: i64) -> sqlx::Result<Vec<Comment>> {
        sqlx::query_as!(
            Comment,
            "SELECT id, post_id, body, created_at FROM comments WHERE post_id = ? ORDER BY id",
            post_id
        )
        .fetch_all(&self.pool)
        .await
    }

    async fn add_comment(&self, post_id: i64, new: NewComment) -> sqlx::Result<Comment> {
        sqlx::query_as!(
            Comment,
            r#"INSERT INTO comments (post_id, body) VALUES (?, ?) RETURNING id as "id!", post_id, body, created_at"#,
            post_id,
            new.body
        )
        .fetch_one(&self.pool)
        .await
    }

    async fn list_users(&self) -> sqlx::Result<Vec<User>> {
        sqlx::query_as!(User, "SELECT id, email, role, created_at FROM users ORDER BY id")
            .fetch_all(&self.pool)
            .await
    }

    async fn ping(&self) -> sqlx::Result<()> {
        sqlx::query("SELECT 1").execute(&self.pool).await?;
        Ok(())
    }

    async fn add_image(&self, post_id: i64, url: &str) -> sqlx::Result<Image> {
        sqlx::query_as!(
            Image,
            r#"INSERT INTO images (post_id, url) VALUES (?, ?) RETURNING id as "id!", post_id, url"#,
            post_id,
            url
        )
        .fetch_one(&self.pool)
        .await
    }
}

fn fts_query(input: &str) -> String {
    input
        .split_whitespace()
        .map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
        .collect::<Vec<_>>()
        .join(" ")
}

Recap

  • A procedural macro is a function in the compiler: TokenStream in, TokenStream out
  • It lives in its own crate with [lib] proc-macro = true
  • syn parses (DeriveInput, Data::Struct, Fields::Named); quote! generates (#name, #(#items),*)
  • Never panic: return syn::Error::new_spanned(...).to_compile_error() for a clear, located error
  • attributes(columns) plus parse_nested_meta adds helper attributes like #[columns(prefix = "p")]

Next episode: what is a Future really? We write one by hand, and meet Pin. Watch the series on YouTube.

ยฉ 2026 All Rights Reserved By Mahmudul Alam