Procedural Macros

Declarative Macros (macro_rules!) covered pattern-matching macros, perfect for terse, syntax-driven expansion. But macro_rules! can only match and reshuffle tokens; it can't inspect a struct's fields, read attributes, or generate code that depends on the meaning of its input. That's where procedural macros come in: a proc macro is a Rust function that takes a TokenStream, runs arbitrary Rust code at compile time, and returns a TokenStream. Every #[derive(Debug)], #[tokio::main], and #[derive(Serialize)] you've used is one. This tutorial covers the three kinds, the syn + quote workflow, and the structural rules that trip people up first.


The Three Kinds

A procedural macro is always one of three forms, distinguished by how it's invoked:

  • Derive macros#[derive(MyTrait)] on a struct/enum. Generate an additional impl; they can't modify the annotated item, only add alongside it. This is by far the most common kind (Derive Macros and Common Traits).
  • Attribute macros#[my_attr] on almost any item (function, struct, module). Receive the item and replace it with transformed output. #[tokio::main] rewrites your async fn main into a sync one that starts a runtime.
  • Function-like macrosmy_macro!(...), called like a macro_rules! macro but with full procedural power over the tokens inside.
use proc_macro::TokenStream;

#[proc_macro_derive(Builder)]
pub fn derive_builder(input: TokenStream) -> TokenStream { /* ... */ }

#[proc_macro_attribute]
pub fn trace(attr: TokenStream, item: TokenStream) -> TokenStream { /* ... */ }

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream { /* ... */ }

Note the signatures differ: a derive takes one TokenStream (the item), an attribute takes two (the attribute's own args, then the item), and a function-like takes one (the tokens between the parentheses).


The Separate-Crate Rule

The first wall everyone hits is structural, not about logic: proc macros must live in their own dedicated crate marked proc-macro = true. They can't sit next to the code that uses them.

# in the proc-macro crate's Cargo.toml
[lib]
proc-macro = true

[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"

Gotcha: you cannot define a proc macro in the same crate that calls it. The macro runs at compile time of the consumer, so it must already be compiled into a separate proc-macro crate first. The standard layout is a pair: mylib (the normal crate) plus mylib-macros (the proc-macro = true crate), with mylib re-exporting the macros so users see one unified API. Forgetting proc-macro = true, or trying to put the macro inline, produces confusing "cannot find derive macro" errors that have nothing to do with your actual code.


syn and quote: Parse, Then Generate

The raw TokenStream is an untyped stream of tokens, working with it directly is miserable. Two crates make proc macros tractable: syn parses a TokenStream into a typed syntax tree (a DeriveInput, an ItemFn, etc.), and quote turns Rust-like template syntax back into a TokenStream. The whole pattern is parse → inspect → generate:

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};

#[proc_macro_derive(Describe)]
pub fn derive_describe(input: TokenStream) -> TokenStream {
    // 1. parse the input into a typed tree
    let ast = parse_macro_input!(input as DeriveInput);
    let name = &ast.ident;                 // the struct/enum name

    // 2. generate new code with quote!; #name interpolates the parsed value
    let expanded = quote! {
        impl Describe for #name {
            fn describe(&self) -> String {
                format!("an instance of {}", stringify!(#name))
            }
        }
    };

    // 3. hand the generated tokens back to the compiler
    expanded.into()
}

quote! uses #name interpolation (like macro_rules!'s $name, but pulling from ordinary Rust variables) and #(...)* repetition for iterating over collected fields. The mental model: you're writing a normal Rust function whose return value is source code. Everything in between, looping over ast.data's fields, reading attributes, branching on struct vs enum, is just regular Rust you run at compile time.


Spans and Error Messages

A proc macro that panic!s on bad input produces a useless error pointing at the macro, not the user's code. The mark of a well-built proc macro is errors that point at the exact offending token in the caller's source. syn::Error carries a span (a source location) and converts into a compile_error! invocation that the compiler reports at that location:

// good: a targeted compile error at the user's field, not a panic
return syn::Error::new_spanned(field, "Builder fields cannot be unnamed")
    .to_compile_error()
    .into();

Gotcha: prefer returning syn::Error::to_compile_error() over panic! or .unwrap() in a proc macro. A panic aborts macro expansion with a message that blames the macro internals and gives the user no actionable location; a spanned syn::Error produces a normal red squiggle under the precise token they got wrong. Good span handling is the difference between a macro that's pleasant to use and one that's cryptic.


When to Reach for a Proc Macro

NeedReach for
Terse syntax sugar, pure token reshufflingmacro_rules! (declarative)
Generate a trait impl from a type's fieldsderive macro
Wrap/rewrite a function or itemattribute macro
A DSL or compile-time-checked literal (SQL, regex)function-like macro
Anything needing to inspect the input's structurea proc macro (any kind)

The dividing line: if you only need to match and substitute tokens, macro_rules! is simpler, lives inline, and compiles faster. Reach for a proc macro when you must understand the input, read fields, branch on types, parse attributes, validate, because only a proc macro can run real Rust over a parsed syntax tree. Proc macros are powerful but cost compile time and a separate crate, so don't reach for one when a derive already exists or a declarative macro would do.


Key Takeaways

  • A procedural macro is a compile-time Rust function from TokenStream to TokenStream, in one of three forms: derive (adds an impl), attribute (rewrites an item), or function-like (name!(...)).
  • Proc macros must live in a dedicated crate with proc-macro = true; you can't define one in the crate that uses it. The idiomatic layout is a lib + lib-macros pair with the macros re-exported.
  • Use syn to parse the input into a typed AST and quote! to generate the output, the parse → inspect → generate pattern. #name interpolation and #(...)* repetition build the returned code from ordinary Rust variables.
  • Return syn::Error::to_compile_error() with a span instead of panicking, so errors point at the exact token in the caller's code rather than blaming the macro.
  • Choose macro_rules! for pure token substitution; reach for a proc macro only when you need to inspect the input's structure (fields, types, attributes). The power costs compile time and a separate crate.