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.
- 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 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:
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:
[package]
name = "blog_macros"
version = "0.1.0"
edition = "2024"The function, in blog_macros/src/lib.rs, empty for now:
use proc_macro::TokenStream;
#[proc_macro_derive(Columns)]
pub fn derive_columns(input: TokenStream) -> TokenStream {
let _ = input;
TokenStream::new()
}3. The crate type
cd blog_macros && cargo builderror: the `#[proc_macro_derive]` attribute is only usable with crates of the `proc-macro` crate typeThe 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:
[lib]
proc-macro = true
[dependencies]
syn = "2"
quote = "1"4. Parse, collect, generate
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 aDeriveInput: 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.#nameputs a value into the code;#(#columns),*repeats it with commas, like$( ),*inmacro_rules!. EachStringbecomes a string literal.
For Post, it generates impl Post { pub fn columns() -> &'static [&'static str] { &["id", "title", ...] } }.
5. Use it in the blog
cargo add blog_macros --path blog_macrosuse blog_macros::Columns;
#[derive(Serialize, Deserialize, Clone, sqlx::FromRow, ToSchema, Columns)]
pub struct Post {and the query uses Post::columns(), joined with commas:
let mut q = QueryBuilder::<Sqlite>::new(format!("SELECT {} FROM posts p WHERE p.deleted_at IS NULL", Post::columns().join(", ")));cargo testGreen. 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:
use blog_macros::Columns;
#[derive(Columns)]
enum Sort {
Newest,
Oldest,
}
fn main() {}cargo build --example bad_deriveerror: proc-macro derive panicked
= help: message: not a structNot 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:
_ => return syn::Error::new_spanned(name, "Columns only works on structs with named fields").to_compile_error().into(),(in both _ arms). Now:
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:
#[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:
#[derive(Serialize, Deserialize, Clone, sqlx::FromRow, ToSchema, Columns)]
#[columns(prefix = "p")]
pub struct Post {Remove the bad example, and run the tests:
rm examples/bad_derive.rs
cargo testGreen. The query string comes from the struct: add a field, and the query follows by itself.
The final code
blog_macros/Cargo.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
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
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
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:
TokenStreamin,TokenStreamout - It lives in its own crate with
[lib] proc-macro = true synparses (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)plusparse_nested_metaadds 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.