When boilerplate becomes a burden
You're building a game engine. You've defined a Serializable trait to save and load game objects. You have a Player struct, an Enemy struct, a Weapon struct, and forty others. Writing impl Serializable for Player by hand is tedious. Worse, if you add a field to Player, you have to remember to update the impl block. You want to type #[derive(Serializable)] on the struct and have Rust generate the boilerplate automatically. That's what a procedural derive macro does. It turns a single attribute into a whole block of generated code.
How derive macros work
Think of a derive macro as a code-writing robot that lives inside the compiler. When the compiler sees #[derive(MyMacro)], it pauses, hands the macro the source code of the struct, and waits. The macro reads the struct, writes new Rust code, and hands it back. The compiler then compiles that new code as if you had typed it yourself. The macro doesn't run when your program runs. It runs while your program is being built. This distinction matters. Macros expand to text. They don't execute logic at runtime.
The ecosystem relies on two crates to make this manageable. syn parses Rust source code into a data structure you can inspect. quote builds Rust code from data structures back into tokens. Writing a parser by hand is error-prone and reinvents the wheel. syn handles Rust's grammar quirks. quote ensures the output is valid token streams. Stick to the ecosystem tools.
Minimal example
Create a library crate and configure it as a proc-macro.
# Cargo.toml
[lib]
proc-macro = true // Tells Cargo this crate produces macros, not a normal library.
[dependencies]
syn = "2.0" // Parses Rust source code into a data structure.
quote = "1.0" // Builds Rust code from data structures back into tokens.
Implement the derive function.
// src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
/// Generates an empty impl block for MyTrait.
#[proc_macro_derive(MyTrait)] // Registers this function as the implementation for #[derive(MyTrait)].
pub fn my_trait_derive(input: TokenStream) -> TokenStream {
// Parse the raw tokens into a structured representation of the struct or enum.
let input = parse_macro_input!(input as DeriveInput);
// Grab the name of the type the user applied the derive to.
let name = &input.ident;
// Generate the implementation code.
// The quote! macro lets you write Rust-like syntax that interpolates variables.
let expanded = quote! {
impl MyTrait for #name {}
};
// Return the generated code to the compiler.
TokenStream::from(expanded)
}
Keep the macro function focused. Parse, transform, return. Don't hide runtime logic here.
Walkthrough
When you write #[derive(MyTrait)] struct Foo;, the compiler invokes your function. The input argument contains the tokens for struct Foo. The parse_macro_input! macro converts those tokens into a DeriveInput struct, which breaks the code down into fields like ident (the name), generics, and data (the body). You extract the name and feed it into quote!. The quote! macro builds a new token stream representing impl MyTrait for Foo {}. You return that stream. The compiler inserts the impl block right next to the struct definition and continues compilation.
When parsing inside a macro, always prefer parse_macro_input! over syn::parse. The macro helper attaches the error to the #[derive(...)] line, making it obvious where the problem is. Raw parsing often points to the macro definition file, which confuses users.
Realistic example
A useful macro inspects fields and generates code based on them. This example creates a LogFields derive that implements Display by printing each field name and value.
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};
/// Generates a Display impl that lists field names and values.
#[proc_macro_derive(LogFields)]
pub fn log_fields_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
// Extract fields from the struct. Enums require a different structure.
let fields = match &input.data {
Data::Struct(data_struct) => &data_struct.fields,
_ => {
// Return a compile error instead of panicking.
let error = syn::Error::new_spanned(&input.ident, "LogFields only supports structs");
return error.to_compile_error().into();
}
};
// Build a fragment for each field that writes "name: value".
let field_prints: Vec<_> = fields.iter().map(|f| {
let name = &f.ident;
quote! {
write!(f, "{}: {{:?}}", stringify!(#name), self.#name)?;
}
}).collect();
// Generate the full impl block.
let expanded = quote! {
impl std::fmt::Display for #name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Iterate over the generated fragments.
#(
#field_prints
)*
Ok(())
}
}
};
TokenStream::from(expanded)
}
Generate code that compiles. If the expansion fails, the user blames you.
Handling generics
Structs often have type parameters. A Vec<T> has a generic. If your macro ignores generics, it breaks on parameterized types. The DeriveInput struct stores generics separately. Use generics.split_for_impl() to get the three parts needed for an impl block. This method handles the punctuation and where clauses automatically.
let generics = &input.generics;
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
let expanded = quote! {
impl #impl_generics MyTrait for #name #ty_generics #where_clause {
fn process(&self) {
// Implementation here
}
}
};
Trust split_for_impl(). Generic syntax is a minefield.
Debugging macros
Macros generate code that the compiler hides by default. When something goes wrong, you need to see the output. Use cargo expand to print the generated source. Install it with cargo install cargo-expand. Run cargo expand in the crate using the macro. The output shows the code after macro expansion. This reveals typos, missing imports, or logic errors in the generation. If cargo expand isn't available, use cargo rustc -- -Z unpretty=expanded on nightly.
Never ship a macro you haven't expanded. The output is the truth.
Pitfalls and errors
Macros generate code that runs in the user's crate context. If your macro generates impl std::fmt::Display, the user's crate must have access to std. If you generate use serde::Serialize;, you're adding a dependency the user might not have. Always use fully qualified paths in generated code, or assume the user has imported what they need. If you reference a type that doesn't exist, the compiler rejects the expansion with E0433 (can't find crate or module). The error points to the generated code, which can be confusing. Use quote! carefully to avoid leaking internal types.
Never use panic! to report errors in a macro. A panic stops the compiler and prints a backtrace that looks like a bug in the toolchain. Use syn::Error instead. Call .to_compile_error() to return a valid token stream that produces a clean error message.
Treat macro errors like user errors. Return clean diagnostics, not compiler crashes.
When to use a procedural derive macro
Use a procedural derive macro when you need to generate trait implementations based on the structure of a type. Use a procedural derive macro when the logic depends on inspecting fields, generics, or attributes that declarative macros cannot access. Use a declarative macro when you are repeating a pattern of expressions or statements and don't need to parse type definitions. Reach for a manual trait implementation when the logic is simple enough to write by hand; macros add complexity and compilation time. Pick a build script when you need to generate code based on external files or environment variables rather than Rust syntax.
Macros are powerful tools. Use them to eliminate repetition, not to obfuscate logic.