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 |
| A struct or enum — generates new code alongside it |
Attribute-like |
| Any item — can transform it entirely |
Function-like |
| 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.
# 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:
# my_app/Cargo.toml
[dependencies]
my_macros = { path = "../my_macros" }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 aTokenStreaminto a structured Rust AST (abstract syntax tree). Withoutsyn, you would need to manually iterate over raw tokens.quote— generates Rust code from a template usingquote!{ ... }syntax. It handles escaping and produces aTokenStreamready to return from your macro.proc_macro2— a re-export of the compiler's token types that works outside the compiler (useful for testing).
// 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 interpolation — quote replaces it with the
actual identifier at compile time. This is the mirror of $var in macro_rules!.
Using the Custom Derive
// 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!
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.
// 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()
}// 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.
// 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()
}#[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]:
// 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)Popular Crates Using Procedural Macros
Crate | Macro | What it generates |
|---|---|---|
serde |
| JSON/binary serialisation implementations |
tokio |
| Async runtime setup around your main function |
thiserror |
| Error trait implementations with formatted messages |
clap |
| CLI argument parsing from struct field definitions |
sqlx |
| Compile-time verified SQL queries |
async-trait |
| Async methods in trait definitions (a language gap filler) |
derive_more |
| 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.
// 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");
}// 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();
}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-writtenserializemethod at runtime#[tokio::main]inlines the runtime setup code — no extra function calls at startupCompile-time SQL validation in
sqlx::query!adds zero overhead to query executionThe 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 | A |
You need to accept non-Rust syntax (SQL, HTML, custom DSL) | The transformation is simple enough for |
You are implementing compile-time verification (SQL, config) | Runtime validation is acceptable for your use case |