Rust Procedural Macros: Write Your Own #[derive]
Every time you write #[derive(Debug)] or #[derive(Serialize)], a small program runs inside the compiler. It looks at your struct and writes an impl block for you. That small program is a procedural macro, and you can write your own.
Think of it like a helpful clerk. You hand over a form (your struct), the clerk reads every field, and hands back the boring paperwork already filled in. On the Macros page you met macro_rules!, which works by matching patterns. Procedural macros are different: they are ordinary Rust functions that take code in and give code back. On this page you’ll build a real one, step by step, and see the real output.
The three kinds of procedural macros
Section titled “The three kinds of procedural macros”There are three kinds. They all work the same way inside, but they look different to the person using them:
| Kind | What the user writes | Famous examples |
|---|---|---|
| Derive macro | #[derive(Serialize)] on a struct or enum |
serde’s Serialize, thiserror’s Error, clap’s Parser |
| Attribute macro | #[route(GET, "/")] on a function or item |
#[tokio::main], actix-web’s #[get("/")] |
| Function-like macro | sql!(SELECT * FROM users) |
sqlx’s query!, yew’s html! |
- A derive macro only adds code next to your type. Your struct stays exactly as you wrote it.
- An attribute macro replaces the item it’s on.
#[tokio::main]takes yourasync fn mainand turns it into a normalfn mainthat starts the tokio runtime. - A function-like macro looks like a
macro_rules!call, but a Rust function decides what the input means. That’s howsqlx::query!can check your SQL at compile time.
You already use derive macros from other crates. Here are two of them doing real work. Serialize writes the JSON code (see JSON with Serde), and Error writes the Display code from the #[error(...)] text (see anyhow and thiserror):
use serde::Serialize;use thiserror::Error;
#[derive(Serialize)]struct Order { id: u32, item: String, quantity: u8,}
#[derive(Debug, Error)]enum ShopError { #[error("item {0} is out of stock")] OutOfStock(String),}
fn main() { let order = Order { id: 42, item: "Teapot".to_string(), quantity: 2 }; println!("{}", serde_json::to_string(&order).unwrap());
let err = ShopError::OutOfStock("Teapot".to_string()); println!("{err}");}Output:
{"id":42,"item":"Teapot","quantity":2}item Teapot is out of stockYou never wrote a Display impl or any JSON code. The derives wrote it while your program compiled.
How they work: tokens in, tokens out
Section titled “How they work: tokens in, tokens out”The compiler first cuts your source code into tokens: names, punctuation, literals, brackets. A procedural macro is a function that gets those tokens as a TokenStream and returns a new TokenStream. The compiler then compiles whatever you returned, as if you had typed it yourself.
Here are the three function shapes. You mark each one with an attribute so the compiler knows which kind it is:
use proc_macro::TokenStream;
// #[derive(HelloMacro)]: gets the struct or enum, returns code to ADD.#[proc_macro_derive(HelloMacro)]pub fn hello_macro_derive(input: TokenStream) -> TokenStream { /* ... */ }
// #[route(GET, "/")]: gets the attribute's own tokens and the item, returns a REPLACEMENT.#[proc_macro_attribute]pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream { /* ... */ }
// sql!(...): gets everything inside the brackets, returns the replacement.#[proc_macro]pub fn sql(input: TokenStream) -> TokenStream { /* ... */ }A raw TokenStream is just a list of tokens, which is hard to work with. So almost every procedural macro uses three helper crates:
- syn parses tokens into a syntax tree. Instead of tokens, you get a
DeriveInputwith the type’s name, its generics and its fields. - quote turns Rust-looking code back into tokens. Inside
quote! { ... }you write normal Rust, and#namepastes in a variable. - proc-macro2 is a wrapper around the compiler’s
proc_macrotypes that syn and quote use. It also works outside the compiler, which makes helper functions and tests easier.
One important idea: your macro runs at compile time. Cargo compiles the macro crate first, for your own computer, and the compiler runs it while it builds the crate that uses it. When the program finally runs, the macro is long gone. Only the code it wrote is left.
Setting up the project
Section titled “Setting up the project”A procedural macro must live in its own crate with a special crate type. That crate can only export macros, not normal functions or traits. So the usual layout is two crates side by side, like serde and serde_derive: one for the trait, one for the derive. We’ll add a third crate, a small program called shop that uses them. (Need a refresher on crates and workspaces? See Cargo & Crates.)
mkdir procmacro-demo && cd procmacro-democargo new --lib hello_macrocargo new --lib hello_macro_derivecargo new shopHere’s the finished file tree:
procmacro-demo/├── Cargo.toml (the workspace)├── hello_macro/│ ├── Cargo.toml│ └── src/lib.rs (the traits, and a re-export of the derives)├── hello_macro_derive/│ ├── Cargo.toml (proc-macro = true)│ └── src/lib.rs (the macros themselves)└── shop/ ├── Cargo.toml └── src/main.rs (a program that uses #[derive(...)])The top-level Cargo.toml ties the three crates into one workspace:
[workspace]members = ["hello_macro", "hello_macro_derive", "shop"]resolver = "2"The macro crate is the only one with something special: proc-macro = true. It also depends on syn, quote and proc-macro2:
[package]name = "hello_macro_derive"version = "0.1.0"edition = "2024"
[lib]proc-macro = true
[dependencies]proc-macro2 = "1"quote = "1"syn = "2"The other two crates are normal. hello_macro depends on the derive crate, and shop only needs hello_macro:
[dependencies]hello_macro_derive = { version = "0.1.0", path = "../hello_macro_derive" }[dependencies]hello_macro = { version = "0.1.0", path = "../hello_macro" }Step 1: #[derive(HelloMacro)]
Section titled “Step 1: #[derive(HelloMacro)]”Let’s start tiny. We want any type with #[derive(HelloMacro)] to get a hello_macro() function that prints the type’s name. First the trait, in hello_macro/src/lib.rs. The last line re-exports the derive macros, so users only need this one crate. That’s exactly what serde does.
pub trait HelloMacro { fn hello_macro();}
pub trait Describe { fn type_name() -> &'static str; fn field_names() -> &'static [&'static str];}
// Re-export the derive macros, so users only need this one crate.pub use hello_macro_derive::{Describe, HelloMacro};(Describe is for step 2.) Now the macro itself, in hello_macro_derive/src/lib.rs:
use proc_macro::TokenStream;use quote::quote;use syn::{parse_macro_input, Data, DeriveInput, Fields}; // Data and Fields: step 2
#[proc_macro_derive(HelloMacro)]pub fn hello_macro_derive(input: TokenStream) -> TokenStream { // 1. Parse the tokens into a syntax tree we can inspect. let ast = parse_macro_input!(input as DeriveInput); let name = &ast.ident;
// 2. Build the new code with quote!. let generated = quote! { impl hello_macro::HelloMacro for #name { fn hello_macro() { println!("Hello, Macro! My name is {}!", stringify!(#name)); } } };
// 3. Hand the new tokens back to the compiler. generated.into()}Three steps, and you’ll see them in almost every derive macro:
parse_macro_input!turns the tokens into aDeriveInput.ast.identis the type’s name, likeProduct. If parsing fails, this macro returns a proper compile error for you.quote!builds theimplblock.#namepastes in the name.stringify!(#name)turns the name into the text"Product"..into()converts quote’sproc_macro2::TokenStreaminto the compiler’sproc_macro::TokenStream.
Notice the full path hello_macro::HelloMacro. The generated code is pasted into the user’s file, so it can’t rely on the user’s use lines.
When someone writes #[derive(HelloMacro)] struct Product, the compiler adds code that works just like this hand-written version. This one runs in the Playground, because there’s no macro crate involved:
trait HelloMacro { fn hello_macro();}
struct Product;
// This is what #[derive(HelloMacro)] writes for you:impl HelloMacro for Product { fn hello_macro() { println!("Hello, Macro! My name is {}!", "Product"); }}
fn main() { Product::hello_macro();}Output:
Hello, Macro! My name is Product!Writing this by hand once is fine. Writing it for 40 types is where a derive starts to pay off.
Step 2: #[derive(Describe)] reads the fields
Section titled “Step 2: #[derive(Describe)] reads the fields”HelloMacro only used the type’s name. Real derives, like serde’s, look at the fields. Let’s build Describe: it lists a struct’s field names, which is handy for a CSV header. syn gives you the fields through ast.data:
Data::Struct,Data::EnumorData::Union: what kind of type it is.- For a struct,
Fields::Named(struct Product { id: u32 }),Fields::Unnamed(struct Meters(f64)) orFields::Unit(struct Marker;).
Add this below the first macro in hello_macro_derive/src/lib.rs:
#[proc_macro_derive(Describe)]pub fn describe_derive(input: TokenStream) -> TokenStream { let ast = parse_macro_input!(input as DeriveInput); expand_describe(&ast) .unwrap_or_else(|err| err.to_compile_error()) .into()}
fn expand_describe(ast: &DeriveInput) -> syn::Result<proc_macro2::TokenStream> { let name = &ast.ident;
let Data::Struct(data) = &ast.data else { return Err(syn::Error::new_spanned( name, "Describe can only be derived for structs", )); };
let field_names: Vec<String> = match &data.fields { Fields::Named(fields) => fields .named .iter() .map(|field| field.ident.as_ref().unwrap().to_string()) .collect(), Fields::Unit => Vec::new(), Fields::Unnamed(_) => { return Err(syn::Error::new_spanned( name, "Describe needs named fields, like `struct Point { x: i32 }`", )); } };
Ok(quote! { impl hello_macro::Describe for #name { fn type_name() -> &'static str { stringify!(#name) }
fn field_names() -> &'static [&'static str] { &[#(#field_names),*] } } })}The new pieces:
- The real work happens in a normal helper function that returns
syn::Result. That lets you use?andreturn Err(...)like in any other error-handling code. The macro function at the top just turns an error into a compile error. let ... elsehandles “not a struct” right away and keeps the happy path flat.#(#field_names),*is quote’s repetition. It pastes every string in theVec, separated by commas. It’s the same idea as$(...),*inmacro_rules!. EachStringbecomes a string literal, so this turns into&["id", "name", "price_cents"].field.ident.as_ref().unwrap()is safe here: inFields::Named, every field has a name.
Now use both derives in shop/src/main.rs. Notice that use hello_macro::{Describe, HelloMacro} brings in the traits and the derive macros. They share a name but live in different namespaces, which is exactly how serde::Serialize works.
use hello_macro::{Describe, HelloMacro};
#[derive(HelloMacro, Describe)]struct Product { id: u32, name: String, price_cents: u64,}
#[derive(HelloMacro, Describe)]struct Customer { email: String, city: String,}
fn main() { Product::hello_macro(); Customer::hello_macro();
println!("{} has fields {:?}", Product::type_name(), Product::field_names());
// A CSV file: the header comes from the derive, the rows from real values. let ana = Customer { email: "ana@example.com".to_string(), city: "Lisbon".to_string() }; println!("{}", Customer::field_names().join(",")); println!("{},{}", ana.email, ana.city);
let teapot = Product { id: 7, name: "Teapot".to_string(), price_cents: 1999 }; println!("{} #{}: {} costs {} cents", Product::type_name(), teapot.id, teapot.name, teapot.price_cents);}Run it from the workspace folder:
cargo run -p shopOutput (on a fresh build):
Compiling proc-macro2 v1.0.107 Compiling unicode-ident v1.0.27 Compiling quote v1.0.47 Compiling syn v2.0.119 Compiling hello_macro_derive v0.1.0 (/home/you/procmacro-demo/hello_macro_derive) Compiling hello_macro v0.1.0 (/home/you/procmacro-demo/hello_macro) Compiling shop v0.1.0 (/home/you/procmacro-demo/shop) Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.45s Running `target/debug/shop`Hello, Macro! My name is Product!Hello, Macro! My name is Customer!Product has fields ["id", "name", "price_cents"]email,cityana@example.com,LisbonProduct #7: Teapot costs 1999 centsLook at the order: Cargo builds syn and hello_macro_derive before shop, because the compiler needs to run your macro while it compiles shop. If you add a field to Customer, the CSV header updates by itself next time you build.
Good error messages
Section titled “Good error messages”What happens if someone puts #[derive(Describe)] on an enum? Because expand_describe returns a syn::Error, the user gets a normal-looking compiler error. (We put the broken examples on this page in shop/src/bin/broken.rs, which is why that path shows up.)
use hello_macro::Describe;
#[derive(Describe)]enum OrderStatus { Pending, Shipped,}error: Describe can only be derived for structs --> shop/src/bin/broken.rs:4:6 |4 | enum OrderStatus { | ^^^^^^^^^^^Two things make this error good:
syn::Error::new_spanned(name, ...)remembers where the name is in the user’s file (its span). That’s why the^^^^^points atOrderStatusand not at some line inside your macro.to_compile_error()turns the error into acompile_error!("...")call. The compiler then reports it like any other error. You can use the samecompile_error!macro in normal code, too.
A tuple struct gets the other message, also pointing at the right place:
error: Describe needs named fields, like `struct Point { x: i32 }` --> shop/src/bin/broken.rs:4:8 |4 | struct Meters(f64); | ^^^^^^Write your messages for the person using the macro. Say what’s wrong and what would work instead.
Seeing the generated code with cargo expand
Section titled “Seeing the generated code with cargo expand”Macros can feel like magic, and magic is hard to debug. cargo-expand shows you the code after every macro has run. Install it once (see Rust Tools & Setup for more handy tools):
cargo install cargo-expandcargo expand -p shopPart of what it prints for our project:
impl hello_macro::Describe for Product { fn type_name() -> &'static str { "Product" } fn field_names() -> &'static [&'static str] { &["id", "name", "price_cents"] }}impl hello_macro::Describe for Customer { fn type_name() -> &'static str { "Customer" } fn field_names() -> &'static [&'static str] { &["email", "city"] }}There’s your impl block, with the field names filled in, and stringify!(#name) already turned into "Product". When a derive doesn’t do what you expect, run cargo expand first. It also expands println! and other macros, so look for your own impl blocks.
Testing your macro
Section titled “Testing your macro”A macro crate can’t easily test itself, because it can only export macros. The simple solution is to write normal tests in a crate that uses the macro. Put this in hello_macro/tests/describe.rs and run cargo test -p hello_macro:
use hello_macro::Describe;
#[allow(dead_code)]#[derive(Describe)]struct Product { id: u32, name: String,}
#[test]fn lists_field_names_in_order() { assert_eq!(Product::type_name(), "Product"); assert_eq!(Product::field_names(), ["id", "name"]);}running 1 testtest lists_field_names_in_order ... okBut how do you test that the enum case gives a good error? A normal test can’t, because the test file wouldn’t compile. That’s what the trybuild crate is for. You put small “should fail” files in a folder, and trybuild compiles each one and compares the compiler’s error with a saved .stderr file:
#[test]fn ui() { let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/*.rs");}Most popular macro crates, including serde and thiserror, test their error messages this way.
When should you write one?
Section titled “When should you write one?”Procedural macros are powerful, but they cost something. Be honest with yourself before you write one:
- Compile time. syn is a big crate. The first build of every project that uses your macro must compile it. (syn’s default features are enough for derives. You only need
features = ["full"]to parse things like function bodies, and that makes it slower.) - Complexity. You now have an extra crate, code that writes code, and errors that are harder to follow.
- Tooling. Editors and rust-analyzer understand plain code better than code from a macro.
A good order to try things:
- A function, a trait with a default method, or generics. Most “repetitive code” problems end here.
- A
macro_rules!macro (see Macros). It needs no extra crate. - A procedural macro, when the code depends on the structure of a type (its name, fields or variants), many types need it, and other people will use it. That’s why serde, thiserror and clap use derives.
For comparison, here’s Describe done with macro_rules!. It works and needs no extra crate, but the struct has to be written inside the macro call. That gets awkward fast, for example if you also want doc comments, pub fields or other derives.
trait Describe { fn type_name() -> &'static str; fn field_names() -> &'static [&'static str];}
macro_rules! describe_struct { (struct $name:ident { $($field:ident : $ty:ty),* $(,)? }) => { struct $name { $($field: $ty),* }
impl Describe for $name { fn type_name() -> &'static str { stringify!($name) } fn field_names() -> &'static [&'static str] { &[$(stringify!($field)),*] } } };}
describe_struct!(struct Customer { email: String, city: String });
fn main() { let ana = Customer { email: "ana@example.com".to_string(), city: "Lisbon".to_string() }; println!("{} has fields {:?}", Customer::type_name(), Customer::field_names()); println!("{},{}", ana.email, ana.city);}Output:
Customer has fields ["email", "city"]ana@example.com,LisbonFor one or two types in your own program, this (or just writing the impl by hand) is often the better choice. A derive is nicer for the people using it: they keep their normal struct and add one line.
Common mistakes
Section titled “Common mistakes”Forgetting proc-macro = true. Without the [lib] section in the macro crate’s Cargo.toml, the crate is a normal library, and the compiler refuses:
error: the `#[proc_macro_derive]` attribute is only usable with crates of the `proc-macro` crate type --> hello_macro_derive/src/lib.rs:5:1 |5 | #[proc_macro_derive(HelloMacro)] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error[E0432]: unresolved import `proc_macro`Using panic! or unwrap() for user mistakes. It “works”, but the error is vague and points at the whole #[derive], not at the problem:
error: proc-macro derive panicked --> shop/src/bin/broken.rs:3:10 |3 | #[derive(Describe)] | ^^^^^^^^ | = help: message: Describe can only be derived for structsReturn a syn::Error with new_spanned instead, as in step 2.
Short paths in the generated code. If you write impl Describe for #name instead of impl hello_macro::Describe for #name, it only works when the user happened to import Describe. Someone who writes #[derive(hello_macro::Describe)] gets this:
error[E0405]: cannot find trait `Describe` in this scope --> shop/src/bin/broken.rs:1:10 |1 | #[derive(hello_macro::Describe)] | ^^^^^^^^^^^^^^^^^^^^^ not found in this scope | = note: this error originates in the derive macro `hello_macro::Describe`Always use full paths in quote!: hello_macro::Describe, ::std::fmt::Display, ::core::option::Option.
Practice
Section titled “Practice”These exercises change the real project, so there’s no Try it button. Build the project from this page on your own computer and run cargo run -p shop.
1. Count the fields
Section titled “1. Count the fields”Add a third method, fn field_count() -> usize, to the Describe trait and to the derive. Product::field_count() should return 3. Hint: you already have the field_names vector. Save field_names.len() in a variable before quote!, and paste it in with #.
Show solution
In hello_macro/src/lib.rs, add the method to the trait:
pub trait Describe { fn type_name() -> &'static str; fn field_names() -> &'static [&'static str]; fn field_count() -> usize;}In expand_describe, count the fields and add the method to the generated impl:
let count = field_names.len();
Ok(quote! { impl hello_macro::Describe for #name { fn type_name() -> &'static str { stringify!(#name) }
fn field_names() -> &'static [&'static str] { &[#(#field_names),*] }
fn field_count() -> usize { #count } } })Then println!("{} has {} fields", Product::type_name(), Product::field_count()); in main prints:
Product has 3 fieldsThe counting happens in your macro, at compile time. cargo expand shows the result is just fn field_count() -> usize { 3usize }, a constant with no work left at run time.
2. Support generic structs
Section titled “2. Support generic structs”Try #[derive(Describe)] on a generic struct like struct Page<T> { items: Vec<T>, number: u32 }. It fails with error[E0107]: missing generics for struct Page, because the generated code says impl hello_macro::Describe for Page and forgets the <T>. Fix the derive so generic structs work. Hint: ast.generics.split_for_impl() gives you three pieces to paste into the impl line. See Generics for a reminder of how impl<T> looks.
Show solution
In expand_describe, split the generics and use all three pieces in the impl line:
let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl();
Ok(quote! { impl #impl_generics hello_macro::Describe for #name #ty_generics #where_clause { fn type_name() -> &'static str { stringify!(#name) }
fn field_names() -> &'static [&'static str] { &[#(#field_names),*] } } })Now this works in shop:
#[derive(Describe)]struct Page<T> { items: Vec<T>, number: u32,}
fn main() { let page = Page { items: vec!["a", "b"], number: 1 }; println!("{} {} {:?}", page.items.len(), page.number, Page::<String>::field_names());}2 1 ["items", "number"]impl_generics is <T> (with any bounds), ty_generics is <T>, and where_clause is the where ... part, if there is one. Every serious derive does this, so remember split_for_impl.
- A procedural macro is a Rust function that runs at compile time:
TokenStreamin,TokenStreamout. - There are three kinds: derive (
#[derive(Serialize)]), attribute (#[tokio::main]) and function-like (sql!(...)). - They live in their own crate with
proc-macro = true. A main crate usually re-exports them next to the traits. - syn parses the input (
DeriveInput,Data,Fields), and quote writes the output (#name,#(...),*). - Report user mistakes with
syn::Error::new_spanned(...).to_compile_error(), check the result withcargo expand, and test errors with trybuild. - Try a function, a trait or
macro_rules!first. A procedural macro is worth it when many types need code based on their structure.
Quick check
Section titled “Quick check”Quick check
1. When does a procedural macro's code run?
2. Which kind of procedural macro is #[tokio::main]?
3. What do syn and quote do?
4. Your derive should reject enums. What's the best way?
Score: 0 / 4
Read more
Section titled “Read more”- Procedural Macros — The Rust Book: the official walk-through of a
HelloMacroderive, plus attribute-like and function-like macros. - Procedural Macros — The Rust Reference: the exact rules for the three kinds, crate types, spans and hygiene.
- syn documentation:
DeriveInput,Data,Fields,Errorand the parsing helpers used on this page. - quote documentation: how
#varinterpolation and#(...),*repetition work insidequote!. - proc-macro-workshop: David Tolnay’s free, hands-on projects (a builder derive, a debug derive and more) with tests that guide you step by step.