RustProcedural Macros

Procedural Macros in Rust

Procedural macros are the more powerful — and more complex — of Rust's two macro systems. Where declarative macros match patterns against syntax, procedural macros are ordinary Rust functions that receive a stream of tokens and produce a new stream of tokens. Because they are full Rust programs, they can do anything: parse custom syntax, query external data, generate arbitrarily complex implementations.

The trade-off is complexity: procedural macros must live in their own crate, require additional dependencies, and are harder to test and debug than macro_rules!.

Three Types of Procedural Macros

Type

Syntax

What it operates on

Custom derive

#[derive(MyTrait)]

A struct or enum — generates new code alongside it

Attribute-like

#[my_attribute]

Any item — can transform it entirely

Function-like

my_macro!(tokens)

Arbitrary token input — looks like a function call

Proc Macros Live in Their Own Crate

This is the most important structural rule of procedural macros: they must be defined in a crate whose Cargo.toml declares proc-macro = true. This is a hard compiler requirement, not a convention.

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

[lib]
proc-macro = true

[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"

Your main crate then adds the proc-macro crate as a dependency:

TOML
# my_app/Cargo.toml
[dependencies]
my_macros = { path = "../my_macros" }
Note
Proc-macro crates cannot export regular functions or types — only macro definitions. If you want to share helper types alongside your macros, create a separate my_macros_support crate that both your proc-macro crate and your users depend on.
The syn and quote Crates

Almost every procedural macro uses two essential crates from the ecosystem:

  • syn — parses a TokenStream into a structured Rust AST (abstract syntax tree). Without syn, you would need to manually iterate over raw tokens.

  • quote — generates Rust code from a template using quote!{ ... } syntax. It handles escaping and produces a TokenStream ready to return from your macro.

  • proc_macro2 — a re-export of the compiler's token types that works outside the compiler (useful for testing).

RUST
// my_macros/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Parse the input tokens into a syntax tree
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident; // the name of the struct/enum

    let gen = quote! {
        impl HelloMacro for #name {
            fn hello() {
                println!("Hello from {}!", stringify!(#name));
            }
        }
    };

    gen.into()
}

The #name inside quote!{} is an interpolationquote replaces it with the actual identifier at compile time. This is the mirror of $var in macro_rules!.

Using the Custom Derive

RUST
// my_app/src/main.rs
use my_macros::HelloMacro;

pub trait HelloMacro {
    fn hello();
}

#[derive(HelloMacro)]
struct Pancakes;

#[derive(HelloMacro)]
struct Waffles;

fn main() {
    Pancakes::hello();
    Waffles::hello();
}
Hello from Pancakes!
Hello from Waffles!
Tip
Custom derive macros generate code that is placed after the struct definition — they cannot modify the struct itself. If you need to transform the struct, use an attribute-like macro instead.
A Real Example: Deriving a Describe Trait

Here is a more complete example: automatically deriving a Describe trait that prints the struct's name and all its field names.

RUST
// In the proc-macro crate:
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};

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

    // Extract field names from the struct
    let fields = match &input.data {
        Data::Struct(data) => match &data.fields {
            Fields::Named(fields) => fields.named
                .iter()
                .map(|f| f.ident.as_ref().unwrap().to_string())
                .collect::<Vec<_>>(),
            _ => vec![],
        },
        _ => vec![],
    };

    let field_list = fields.join(", ");

    let expanded = quote! {
        impl Describe for #name {
            fn describe(&self) {
                println!(
                    "Struct '{}' has fields: [{}]",
                    stringify!(#name),
                    #field_list
                );
            }
        }
    };

    expanded.into()
}

RUST
// In your app:
#[derive(Describe)]
struct User {
    name: String,
    age: u32,
    email: String,
}

fn main() {
    let u = User {
        name: String::from("Alice"),
        age: 30,
        email: String::from("alice@example.com"),
    };
    u.describe();
}
Struct 'User' has fields: [name, age, email]
Attribute-Like Macros

Attribute macros are declared with #[proc_macro_attribute]. Unlike custom derive, they receive the item they are applied to and can transform or replace it entirely. They also receive any arguments passed inside the attribute brackets.

RUST
// In the proc-macro crate:
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};

#[proc_macro_attribute]
pub fn log_call(attr: TokenStream, item: TokenStream) -> TokenStream {
    // attr = the arguments inside #[log_call(...)]
    // item = the function this attribute is applied to
    let input = parse_macro_input!(item as ItemFn);
    let fn_name = &input.sig.ident;
    let fn_body = &input.block;
    let fn_sig  = &input.sig;
    let fn_vis  = &input.vis;

    let expanded = quote! {
        #fn_vis #fn_sig {
            println!("-> calling {}", stringify!(#fn_name));
            let result = (|| #fn_body)();
            println!("<- {} returned", stringify!(#fn_name));
            result
        }
    };

    expanded.into()
}

RUST
#[log_call]
fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn main() {
    let result = add(3, 4);
    println!("result = {}", result);
}
-> calling add
<- add returned
result = 7
Function-Like Macros

Function-like macros look exactly like regular macro invocations (my_macro!(...)) but are implemented as procedural macros. They receive the entire token stream inside the parentheses and can parse it however they wish — including using custom syntax that is not valid Rust.

They are declared with #[proc_macro]:

RUST
// In the proc-macro crate:
use proc_macro::TokenStream;

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
    // 'input' contains the raw tokens passed to sql!(...)
    // In a real implementation, you would parse the SQL,
    // validate it at compile time, and generate Rust code.
    let query = input.to_string();
    let expanded = format!(
        r#"{{ println!("Executing SQL: {{}}", {:?}); }}"#,
        query
    );
    expanded.parse().unwrap()
}

// Usage in application code:
// sql!(SELECT * FROM users WHERE id = 1)
Note
Popular crates like sqlx use function-like proc macros to validate SQL queries against an actual database schema at compile time, turning runtime SQL errors into compile errors.
Popular Crates Using Procedural Macros

Crate

Macro

What it generates

serde

#[derive(Serialize, Deserialize)]

JSON/binary serialisation implementations

tokio

#[tokio::main]

Async runtime setup around your main function

thiserror

#[derive(Error)]

Error trait implementations with formatted messages

clap

#[derive(Parser)]

CLI argument parsing from struct field definitions

sqlx

sqlx::query!(...)

Compile-time verified SQL queries

async-trait

#[async_trait]

Async methods in trait definitions (a language gap filler)

derive_more

#[derive(Display, From, ...)]

Common trait implementations for newtype wrappers

Testing Procedural Macros

Testing proc macros is harder than testing regular functions because the macro runs inside the compiler. The trybuild crate is the standard approach: it compiles small test files and checks whether compilation succeeds or fails with the expected error message.

RUST
// tests/compile_tests.rs
#[test]
fn tests() {
    let t = trybuild::TestCases::new();

    // These should compile successfully
    t.pass("tests/ui/pass/*.rs");

    // These should fail with specific error messages
    t.compile_fail("tests/ui/fail/*.rs");
}

RUST
// tests/ui/pass/basic_derive.rs
use my_macros::Describe;

pub trait Describe {
    fn describe(&self);
}

#[derive(Describe)]
struct Point {
    x: f64,
    y: f64,
}

fn main() {
    Point { x: 1.0, y: 2.0 }.describe();
}
Tip
The cargo-expand tool (installed with cargo install cargo-expand) shows you exactly what code your macros generate. Run cargo expand in your project to see the full expanded source — invaluable for debugging.
Performance: Zero Runtime Cost

Procedural macros run at compile time as part of the build process. From the perspective of the running program, there is no difference between code you wrote by hand and code a macro generated — the binary contains identical machine instructions.

This is the fundamental promise of Rust's macro system: expressive metaprogramming with absolutely no runtime overhead. The work happens once when you build; every subsequent run pays nothing.

  • #[derive(Serialize)] is as fast as a hand-written serialize method at runtime

  • #[tokio::main] inlines the runtime setup code — no extra function calls at startup

  • Compile-time SQL validation in sqlx::query! adds zero overhead to query execution

  • The only cost of proc macros is build time — complex macros can slow compilation noticeably

When to Use Procedural Macros

Use proc macros when...

Use other approaches when...

You need to inspect or generate code based on struct fields/variants

A regular function or generic type covers the use case

You want #[derive(MyTrait)] for users of your library

A macro_rules! macro handles the pattern matching you need

You need to accept non-Rust syntax (SQL, HTML, custom DSL)

The transformation is simple enough for macro_rules!

You are implementing compile-time verification (SQL, config)

Runtime validation is acceptable for your use case

Success
Procedural macros are the foundation of some of Rust's most beloved ergonomics — serde's derive, tokio's async runtime, and thiserror's error handling all rely on them. Learning to write and use proc macros unlocks a level of abstraction that keeps both library authors and their users happy: zero boilerplate, zero runtime cost, and full compile-time safety.