Skip to content

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.

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 your async fn main and turns it into a normal fn main that 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 how sqlx::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 stock
▶ Try it Yourself

You never wrote a Display impl or any JSON code. The derives wrote it while your program compiled.

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 DeriveInput with the type’s name, its generics and its fields.
  • quote turns Rust-looking code back into tokens. Inside quote! { ... } you write normal Rust, and #name pastes in a variable.
  • proc-macro2 is a wrapper around the compiler’s proc_macro types 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.

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.)

Terminal window
mkdir procmacro-demo && cd procmacro-demo
cargo new --lib hello_macro
cargo new --lib hello_macro_derive
cargo new shop

Here’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:

hello_macro_derive/Cargo.toml
[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:

hello_macro/Cargo.toml
[dependencies]
hello_macro_derive = { version = "0.1.0", path = "../hello_macro_derive" }
shop/Cargo.toml
[dependencies]
hello_macro = { version = "0.1.0", path = "../hello_macro" }

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.

hello_macro/src/lib.rs
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:

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:

  1. parse_macro_input! turns the tokens into a DeriveInput. ast.ident is the type’s name, like Product. If parsing fails, this macro returns a proper compile error for you.
  2. quote! builds the impl block. #name pastes in the name. stringify!(#name) turns the name into the text "Product".
  3. .into() converts quote’s proc_macro2::TokenStream into the compiler’s proc_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!
▶ Try it Yourself

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::Enum or Data::Union: what kind of type it is.
  • For a struct, Fields::Named (struct Product { id: u32 }), Fields::Unnamed (struct Meters(f64)) or Fields::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 ? and return Err(...) like in any other error-handling code. The macro function at the top just turns an error into a compile error.
  • let ... else handles “not a struct” right away and keeps the happy path flat.
  • #(#field_names),* is quote’s repetition. It pastes every string in the Vec, separated by commas. It’s the same idea as $(...),* in macro_rules!. Each String becomes a string literal, so this turns into &["id", "name", "price_cents"].
  • field.ident.as_ref().unwrap() is safe here: in Fields::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.

shop/src/main.rs
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: "[email protected]".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:

Terminal window
cargo run -p shop

Output (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,city
Product #7: Teapot costs 1999 cents

Look 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.

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 at OrderStatus and not at some line inside your macro.
  • to_compile_error() turns the error into a compile_error!("...") call. The compiler then reports it like any other error. You can use the same compile_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):

Terminal window
cargo install cargo-expand
cargo expand -p shop

Part 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.

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 test
test lists_field_names_in_order ... ok

But 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.

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:

  1. A function, a trait with a default method, or generics. Most “repetitive code” problems end here.
  2. A macro_rules! macro (see Macros). It needs no extra crate.
  3. 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: "[email protected]".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"]
▶ Try it Yourself

For 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.

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 structs

Return 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.

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.

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 fields

The 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.

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: TokenStream in, TokenStream out.
  • 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 with cargo 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

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