Skip to main content

Procedural Macros in Rust

Published: October 29, 2025 Updated: August 28, 2026 Larry Qu 6 min read

Procedural macros are Rust’s most powerful metaprogramming tool. They run at compile time, receive Rust code as input (a TokenStream), and produce Rust code as output. This is how #[derive(Debug)], #[derive(Serialize)], Tokio’s #[tokio::main], and Axum’s route handlers all work.

Declarative vs Procedural Macros

macro_rules! (declarative macros) match syntax patterns and substitute them. They’re fast to write but limited in what they can express.

Procedural macros are full Rust code that runs at compile time — they can inspect types, traverse ASTs, generate complex code, and emit compile errors with custom messages.

macro_rules! Procedural macros
Syntax Pattern-based Full Rust code
AST access No Yes (via syn)
Custom errors No Yes (via quote::quote!)
Separate crate needed No Yes
Use cases Simple text substitution Complex code generation

Three Types of Procedural Macros

1. Custom #[derive] — implement traits automatically:

#[derive(Debug, Clone, Serialize)] // These are proc macros
struct Config { name: String }

2. Attribute-like — transform any item:

#[tokio::main]      // proc macro attribute
#[route(GET, "/")]  // proc macro attribute
async fn main() {}

3. Function-like — look like function calls:

let query = sql!("SELECT * FROM users WHERE id = ?", id);
let html = html! { <div class="title">Hello</div> };

Project Setup

Procedural macros must live in a dedicated crate with proc-macro = true:

# my_macros/Cargo.toml
[package]
name = "my_macros"
version = "0.1.0"
edition = "2021"

[lib]
proc-macro = true

[dependencies]
syn  = { version = "2.0", features = ["full"] }
quote = "1.0"
proc-macro2 = "1.0"

The crate using your macros depends on it normally:

# my_app/Cargo.toml
[dependencies]
my_macros = { path = "../my_macros" }

Example 1: Custom #[derive] — HelloMacro

The simplest possible derive macro:

my_macros/src/lib.rs:

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

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(input as DeriveInput);
    let name = &ast.ident;

    quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello! I'm a {}", stringify!(#name));
            }
        }
    }
    .into()
}

Usage:

use my_macros::HelloMacro;

trait HelloMacro {
    fn hello_macro();
}

#[derive(HelloMacro)]
struct Robot;

#[derive(HelloMacro)]
struct Human;

fn main() {
    Robot::hello_macro(); // Hello! I'm a Robot
    Human::hello_macro(); // Hello! I'm a Human
}

Example 2: Builder Pattern Derive

A real-world derive macro that generates a Builder struct for any struct:

// my_macros/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};

#[proc_macro_derive(Builder)]
pub fn builder_derive(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let name = &input.ident;
    let builder_name = quote::format_ident!("{}Builder", name);

    let fields = match &input.data {
        Data::Struct(s) => match &s.fields {
            Fields::Named(f) => &f.named,
            _ => panic!("Builder only supports named fields"),
        },
        _ => panic!("Builder only supports structs"),
    };

    // Generate builder field declarations (all wrapped in Option)
    let builder_fields = fields.iter().map(|f| {
        let name = &f.ident;
        let ty = &f.ty;
        quote! { #name: Option<#ty> }
    });

    // Generate builder setter methods
    let setters = fields.iter().map(|f| {
        let name = &f.ident;
        let ty = &f.ty;
        quote! {
            pub fn #name(mut self, value: #ty) -> Self {
                self.#name = Some(value);
                self
            }
        }
    });

    // Generate build() method
    let build_fields = fields.iter().map(|f| {
        let name = &f.ident;
        let name_str = name.as_ref().unwrap().to_string();
        quote! {
            #name: self.#name.ok_or_else(|| format!("field '{}' not set", #name_str))?
        }
    });

    // Generate Default init for builder
    let default_fields = fields.iter().map(|f| {
        let name = &f.ident;
        quote! { #name: None }
    });

    quote! {
        pub struct #builder_name {
            #(#builder_fields,)*
        }

        impl #builder_name {
            pub fn new() -> Self {
                Self { #(#default_fields,)* }
            }

            #(#setters)*

            pub fn build(self) -> Result<#name, String> {
                Ok(#name {
                    #(#build_fields,)*
                })
            }
        }

        impl #name {
            pub fn builder() -> #builder_name {
                #builder_name::new()
            }
        }
    }
    .into()
}

Usage:

#[derive(Debug, Builder)]
struct ServerConfig {
    host: String,
    port: u16,
    max_connections: usize,
}

fn main() {
    let config = ServerConfig::builder()
        .host("localhost".to_string())
        .port(8080)
        .max_connections(100)
        .build()
        .unwrap();

    println!("{:?}", config);

    // Missing field — returns Err
    let bad = ServerConfig::builder()
        .host("localhost".to_string())
        .build();
    println!("{:?}", bad); // Err("field 'port' not set")
}

Example 3: Attribute-Like Macro

Attribute macros receive two token streams: the attribute arguments and the item being annotated.

// my_macros/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn, LitStr};

/// Usage: #[log_call("INFO")]
#[proc_macro_attribute]
pub fn log_call(attr: TokenStream, item: TokenStream) -> TokenStream {
    let level = parse_macro_input!(attr as LitStr).value();
    let func = parse_macro_input!(item as ItemFn);

    let func_name = &func.sig.ident;
    let func_name_str = func_name.to_string();
    let vis = &func.vis;
    let sig = &func.sig;
    let body = &func.block;

    quote! {
        #vis #sig {
            println!("[{}] Calling {}", #level, #func_name_str);
            let result = (|| #body)();
            println!("[{}] {} returned", #level, #func_name_str);
            result
        }
    }
    .into()
}

Usage:

#[log_call("INFO")]
fn process_data(data: &str) -> usize {
    data.len()
}

fn main() {
    let len = process_data("hello world");
    println!("Length: {}", len);
    // [INFO] Calling process_data
    // [INFO] process_data returned
    // Length: 11
}

Example 4: Function-Like Macro

Function-like macros receive a TokenStream of everything inside the parentheses:

// my_macros/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse::Parse, parse_macro_input, LitStr, Token};

struct EnvDefault {
    var: LitStr,
    _comma: Token![,],
    default: LitStr,
}

impl Parse for EnvDefault {
    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
        Ok(EnvDefault {
            var: input.parse()?,
            _comma: input.parse()?,
            default: input.parse()?,
        })
    }
}

/// env_or!("DATABASE_URL", "postgres://localhost/mydb")
/// Expands to: std::env::var("DATABASE_URL").unwrap_or_else(|_| "...".to_string())
#[proc_macro]
pub fn env_or(input: TokenStream) -> TokenStream {
    let EnvDefault { var, default, .. } = parse_macro_input!(input as EnvDefault);

    quote! {
        std::env::var(#var).unwrap_or_else(|_| #default.to_string())
    }
    .into()
}

Usage:

let db_url = env_or!("DATABASE_URL", "postgres://localhost/mydb");
let port   = env_or!("PORT", "8080");

Inspecting the AST with syn

syn parses Rust code into an AST. Key types:

use syn::{DeriveInput, Data, Fields, Type, Ident, Attribute, Lit, Meta};

fn inspect_derive(input: &DeriveInput) {
    // Name of the struct/enum
    let name: &Ident = &input.ident;
    println!("Name: {}", name);

    // Generics
    let generics = &input.generics;

    // Attributes like #[serde(rename = "foo")]
    for attr in &input.attrs {
        println!("Attr: {:?}", attr.path().get_ident());
    }

    match &input.data {
        Data::Struct(s) => {
            match &s.fields {
                Fields::Named(fields) => {
                    for field in &fields.named {
                        let field_name = field.ident.as_ref().unwrap();
                        let field_type = &field.ty;
                        println!("  Field: {} : {:?}", field_name, quote::quote!(#field_type).to_string());
                    }
                }
                Fields::Unnamed(fields) => {
                    for (i, field) in fields.unnamed.iter().enumerate() {
                        println!("  .{}: {:?}", i, &field.ty);
                    }
                }
                Fields::Unit => println!("  Unit struct"),
            }
        }
        Data::Enum(e) => {
            for variant in &e.variants {
                println!("  Variant: {}", variant.ident);
            }
        }
        Data::Union(_) => println!("  Union"),
    }
}

Emitting Compile Errors

Use syn::Error::new_spanned to emit errors that point to the right source location:

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

#[proc_macro_derive(OnlyForStructs)]
pub fn only_for_structs(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);

    match &input.data {
        Data::Struct(_) => {
            // Generate code...
            quote::quote!().into()
        }
        _ => {
            // Emit a compile error pointing to the type name
            syn::Error::new_spanned(
                &input.ident,
                "OnlyForStructs can only be derived for structs"
            )
            .to_compile_error()
            .into()
        }
    }
}

Testing Procedural Macros

Test macros by writing integration tests in the consuming crate. Use trybuild for testing compiler error messages:

# Cargo.toml
[dev-dependencies]
trybuild = "1.0"
// tests/derive_tests.rs
#[test]
fn ui_tests() {
    let t = trybuild::TestCases::new();
    t.pass("tests/ui/basic.rs");
    t.compile_fail("tests/ui/not_a_struct.rs");
}

Real-World Procedural Macro Crates

These popular crates are implemented as procedural macros — studying their source is the best way to learn advanced patterns:

Summary

Macro type Attribute Use for
Custom derive #[proc_macro_derive(Name)] Implementing traits on structs/enums
Attribute macro #[proc_macro_attribute] Transforming functions, structs, any item
Function-like #[proc_macro] DSLs, complex code generation

The workflow is always the same:

  1. Receive TokenStream input
  2. Parse with syn into a typed AST
  3. Generate new code with quote!
  4. Return the new TokenStream

Resources

Comments

👍 Was this article helpful?