Procedural Macros
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in BCP 14 RFC 2119 RFC 8174 when, and only when, they appear in all capitals, as shown here.
This guide covers derive, attribute, and function-like procedural macros: crate layout, parsing with syn, code generation with quote, spanned errors, and compile-fail testing with trybuild. The toolchain, lint, formatting, and pre-commit rules in the Rust Style Guide apply and are not repeated here, and so do the dependency and CI rules; every version quoted is listed in the Tested Version Matrix and was current on 8 September 2026.
Projects MAY create procedural macros to reduce boilerplate. Procedural
macros MUST be defined in a separate crate with proc-macro = true.
Why Procedural Macros
- Code generation: Generate boilerplate at compile time
- Custom derives: Implement traits automatically
- DSLs: Create domain-specific languages
- Validation: Enforce invariants at compile time
Types of Procedural Macros
| Type | Syntax | Use Case |
|---|---|---|
| Derive macros | #[derive(MyTrait)] | Auto-implement traits |
| Attribute macros | #[my_attr] | Transform items |
| Function-like macros | my_macro!(...) | Custom syntax |
Project Structure
my_project/
├── Cargo.toml
├── src/
│ └── lib.rs
└── my_macro/ # Separate crate for proc-macro
├── Cargo.toml
└── src/
└── lib.rs
# my_macro/Cargo.toml
[package]
name = "my_macro"
version = "0.1.0"
edition = "2024" # REQUIRED: an omitted edition defaults to 2015
rust-version = "1.94.0"
[lib]
proc-macro = true
[dependencies]
syn = { version = "3.0.5", features = ["full"] } # syn[^21]
quote = "1.0.47" # quote[^22]
proc-macro2 = "1.0.107" # proc-macro2[^23]
Every crate manifest MUST declare package.edition.
Why: Cargo defaults an omitted edition to 2015, where use quote::quote;
and use syn::...; do not resolve without an extern crate declaration. The
macro crate above fails to compile with “error[E0432]: unresolved import
quote” until the edition is declared.1
Migrating from syn 2 to syn 3
New macro crates MUST use syn 3. syn 3.0.0 reshaped the syntax tree to
absorb three years of language evolution: Type::BareFn became Type::FnPtr,
Signature::unsafety became a three-way Safety enum, Arm::guard moved to a
Pat::Guard variant, and ten new non-exhaustive *Modifiers structs were
added to reserve room for future syntax.2
Macros MUST round-trip parsed items rather than reassembling them field by field.
Why: field-by-field reassembly drops whatever the macro does not name, and
breaks outright when a syntax tree node gains a field. Destructuring
syn::ItemFn exhaustively against syn 3 fails with
“error[E0027]: pattern does not mention field modifiers”; the same code
written against syn 2 compiled, and silently discarded any modifier syntax the
node did not yet model.
// GOOD: mutate the parsed item and print it back. Attributes, visibility,
// generics, `async`, `const`, `unsafe` and modifiers all survive untouched.
let mut func = parse_macro_input!(item as ItemFn);
let body = &func.block;
let wrapped: Block = parse_quote! {{ /* ... */ #body }};
*func.block = wrapped;
quote! { #func }
// BAD: names four of the five fields. Rejected by syn 3, and lossy under
// syn 2.
let ItemFn { attrs, vis, sig, block } = parse_macro_input!(item as ItemFn);
quote! { #(#attrs)* #vis #sig { /* ... */ #block } }
Where a macro must reject syntax it does not understand, call
.require_empty() on the relevant *Modifiers value: it returns a spanned
error naming the unsupported construct instead of silently ignoring it.2
Derive Macro Example
A derive macro MUST handle every shape of the item it accepts — named,
tuple, and unit structs, and generics — or reject the rest with a spanned
error. It MUST NOT panic!.
Why: panic! in a proc macro surfaces as
“error: proc-macro derive panicked” pointing at the derive attribute, with the
message relegated to a note and no span for the offending item. A returned
syn::Error points at the token that is wrong. Ignoring tuple and unit structs
is worse than rejecting them: #[derive(Builder)] on a tuple struct silently
generates a builder whose build() cannot name any field, and the failure
appears deep inside the expansion.
// my_macro/src/lib.rs
use proc_macro::TokenStream;
use proc_macro2::Span;
use quote::{format_ident, quote};
use syn::spanned::Spanned;
use syn::{
Block, Data, DeriveInput, Error, Fields, Ident, ItemFn, Result, Type, parse_macro_input,
parse_quote,
};
#[proc_macro_derive(Builder)]
pub fn derive_builder(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
expand_builder(&input)
.unwrap_or_else(Error::into_compile_error)
.into()
}
struct Field {
/// `None` for a tuple struct: the accessor is positional.
ident: Option<Ident>,
setter: Ident,
ty: Type,
}
fn collect_fields(data: &Data) -> Result<Vec<Field>> {
let fields = match data {
Data::Struct(s) => &s.fields,
Data::Enum(e) => {
return Err(Error::new(e.enum_token.span, "Builder supports structs only"));
}
Data::Union(u) => {
return Err(Error::new(u.union_token.span, "Builder supports structs only"));
}
};
let out = match fields {
Fields::Named(named) => named
.named
.iter()
.map(|f| {
let ident = f.ident.clone().ok_or_else(|| {
Error::new(f.span(), "named field without an identifier")
})?;
Ok(Field { setter: ident.clone(), ident: Some(ident), ty: f.ty.clone() })
})
.collect::<Result<Vec<_>>>()?,
Fields::Unnamed(unnamed) => unnamed
.unnamed
.iter()
.enumerate()
.map(|(i, f)| Field {
ident: None,
setter: format_ident!("field_{i}", span = f.span()),
ty: f.ty.clone(),
})
.collect(),
Fields::Unit => Vec::new(),
};
Ok(out)
}
fn expand_builder(input: &DeriveInput) -> Result<proc_macro2::TokenStream> {
let name = &input.ident;
let builder = format_ident!("{name}Builder");
let error = format_ident!("{name}BuilderError");
let fields = collect_fields(&input.data)?;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
let decls = fields.iter().map(|f| {
let (setter, ty) = (&f.setter, &f.ty);
quote! { #setter: ::core::option::Option<#ty> }
});
let inits = fields.iter().map(|f| {
let setter = &f.setter;
quote! { #setter: ::core::option::Option::None }
});
let setters = fields.iter().map(|f| {
let (setter, ty) = (&f.setter, &f.ty);
quote! {
pub fn #setter(mut self, value: #ty) -> Self {
self.#setter = ::core::option::Option::Some(value);
self
}
}
});
let takes = fields.iter().map(|f| {
let setter = &f.setter;
let label = setter.to_string();
let value = quote! {
self.#setter.ok_or(#error { field: #label })?
};
match &f.ident {
Some(ident) => quote! { #ident: #value },
None => value,
}
});
let ctor = match fields.first().map(|f| f.ident.is_some()) {
Some(true) => quote! { #name { #(#takes,)* } },
Some(false) => quote! { #name(#(#takes,)*) },
None => quote! { #name },
};
Ok(quote! {
/// Error returned when a required field was never set.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct #error {
/// Name of the field that was left unset.
pub field: &'static str,
}
impl ::core::fmt::Display for #error {
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
::core::write!(f, "missing field: {}", self.field)
}
}
impl ::core::error::Error for #error {}
pub struct #builder #impl_generics #where_clause {
#(#decls,)*
}
impl #impl_generics #builder #ty_generics #where_clause {
#[must_use]
pub fn new() -> Self {
Self { #(#inits,)* }
}
#(#setters)*
pub fn build(self) -> ::core::result::Result<#name #ty_generics, #error> {
::core::result::Result::Ok(#ctor)
}
}
impl #impl_generics ::core::default::Default for #builder #ty_generics #where_clause {
fn default() -> Self {
Self::new()
}
}
impl #impl_generics #name #ty_generics #where_clause {
#[must_use]
pub fn builder() -> #builder #ty_generics {
#builder::new()
}
}
})
}
Attribute Macro Example
An attribute macro MUST preserve the item it decorates: attributes,
visibility, generics, async, and where clauses. Wrapping a body in a
closure MUST NOT be used.
Why: let result = (|| { #body })(); changes the meaning of the function.
? and return inside the body then leave the closure rather than the
function, .await is rejected because the closure is not async, and the
closure captures self by inference. Assigning the block back onto the parsed
ItemFn keeps the original control flow, and a Drop guard logs the exit on
every path including ? and early return.
// my_macro/src/lib.rs
#[proc_macro_attribute]
pub fn log_calls(attr: TokenStream, item: TokenStream) -> TokenStream {
if !attr.is_empty() {
let span = proc_macro2::TokenStream::from(attr).span();
return Error::new(span, "log_calls takes no arguments")
.into_compile_error()
.into();
}
// Mutate the parsed item and print it back. Reassembling the pieces by
// hand silently drops anything the macro does not name.
let mut func = parse_macro_input!(item as ItemFn);
let name = func.sig.ident.to_string();
let body = &func.block;
let wrapped: Block = parse_quote! {{
struct __LogExit(&'static str);
impl ::core::ops::Drop for __LogExit {
fn drop(&mut self) {
::std::println!("[EXIT] {}", self.0);
}
}
let __log_exit = __LogExit(#name);
::std::println!("[ENTER] {}", #name);
#body
}};
*func.block = wrapped;
quote! { #func }.into()
}
Function-like Macro Example
A macro MUST validate only what its documentation and its error message claim. A prefix check is a prefix check, not SQL validation.
Why: naming a macro sql! and checking starts_with("SELECT") promises
compile-time SQL checking and delivers none: sql!(SELECT nonexistent FROM missing) is accepted. If compile-time SQL verification is the goal, use SQLx’s
checked macros against a real schema; see
Data Integrity Testing.
// my_macro/src/lib.rs
#[proc_macro]
pub fn select(input: TokenStream) -> TokenStream {
let tokens = proc_macro2::TokenStream::from(input);
let leading = tokens.clone().into_iter().next();
let ok = matches!(
&leading,
Some(proc_macro2::TokenTree::Ident(i)) if i.to_string().eq_ignore_ascii_case("select")
);
if !ok {
let span = leading.map_or_else(Span::call_site, |t| t.span());
return Error::new(span, "select! accepts SELECT statements only")
.into_compile_error()
.into();
}
let text = tokens.to_string();
quote! { #text }.into()
}
Using the Macros
// src/lib.rs — the consumer crate
pub use my_macro::{Builder, log_calls, select};
#[derive(Builder, Debug, PartialEq, Eq)]
pub struct Config {
pub host: String,
pub port: u16,
}
#[derive(Builder, Debug, PartialEq, Eq)]
pub struct Pair<T>(pub T, pub T)
where
T: Clone;
#[derive(Builder, Debug, PartialEq, Eq)]
pub struct Marker;
#[log_calls]
pub fn double(x: i32) -> i32 {
x * 2
}
#[log_calls]
pub async fn fetch(id: u64) -> Result<u64, ConfigBuilderError> {
if id == 0 {
return Err(ConfigBuilderError { field: "id" });
}
Ok(id)
}
pub const QUERY: &str = select!(SELECT * FROM users WHERE id = 1);
Testing Procedural Macros
// tests/builder.rs
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use macro_user::{Config, Marker, Pair};
#[test]
fn named_struct_builds() {
let config = Config::builder()
.host("localhost".into())
.port(8080)
.build()
.expect("all fields set");
assert_eq!(config.host, "localhost");
assert_eq!(config.port, 8080);
}
#[test]
fn missing_field_names_itself() {
let err = Config::builder().host("localhost".into()).build().unwrap_err();
assert_eq!(err.field, "port");
}
#[test]
fn tuple_struct_builds_positionally() {
let pair = Pair::builder().field_0(1u8).field_1(2u8).build().expect("both set");
assert_eq!(pair, Pair(1, 2));
}
#[test]
fn unit_struct_builds() {
assert_eq!(Marker::builder().build().expect("no fields"), Marker);
}
#[test]
fn attribute_macro_preserves_async_and_early_return() {
let rt = std::thread::spawn(|| {
futures_lite::future::block_on(async { macro_user::fetch(7).await })
})
.join()
.expect("thread");
assert_eq!(rt.expect("id 7 is accepted"), 7);
assert_eq!(macro_user::double(21), 42);
assert_eq!(macro_user::QUERY, "SELECT * FROM users WHERE id = 1");
}
Compile-fail Tests with trybuild
Compile-fail fixtures MUST ship the expected .stderr.
Why: without a golden file, trybuild records whatever the macro currently
prints and passes. The test then asserts only that compilation failed, not that
it failed for the stated reason, so a macro that starts rejecting valid input
still passes. Generate the goldens once with TRYBUILD=overwrite cargo test
and review them like any other source.
[dev-dependencies]
trybuild = "1.0.121" # trybuild[^24]
// tests/compile_fail.rs
#[test]
fn compile_fail_tests() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/compile-fail/*.rs");
}
// tests/compile-fail/builder_on_enum.rs
use macro_user::Builder;
#[derive(Builder)]
enum Invalid {
A,
B,
}
fn main() {}
// tests/compile-fail/builder_on_enum.stderr
error: Builder supports structs only
--> tests/compile-fail/builder_on_enum.rs:4:1
|
4 | enum Invalid {
| ^^^^
// tests/compile-fail/select_rejects_insert.rs
use macro_user::select;
fn main() {
let _ = select!(INSERT INTO users VALUES (1));
}
// tests/compile-fail/select_rejects_insert.stderr
error: select! accepts SELECT statements only
--> tests/compile-fail/select_rejects_insert.rs:4:21
|
4 | let _ = select!(INSERT INTO users VALUES (1));
| ^^^^^^
Proc Macro Best Practices
Errors MUST be returned as syn::Error with the narrowest useful span:
// GOOD: the diagnostic points at the `enum` keyword.
Data::Enum(e) => {
return Err(Error::new(e.enum_token.span, "Builder supports structs only"));
}
// BAD: no span, and the message is buried under
// "error: proc-macro derive panicked".
_ => panic!("Builder only works on structs"),
Attribute parsing SHOULD use darling3 rather than hand-written
Meta walking:
[dependencies]
darling = "0.24.1" # darling[^25]
Why: darling derives the parser from a struct definition, so unknown keys,
missing required keys, and wrong value types all produce spanned errors for
free. Hand-written Meta traversal typically accumulates one if per key and
degrades to ignoring anything unrecognized.
References
See Also
- Rust Style Guide - Toolchain, lints, formatting, dependencies, and CI
- Rust topic guides - The other Rust topic guides
Footnotes
-
The
editionfield - Cargo manifest reference ↩ -
syn 3.0.0 release notes - Breaking syntax-tree changes and the
*Modifiersstructs ↩ ↩2 -
darling - Declarative attribute parser for Rust proc macros ↩