Procedural Macros in Rust: syn, quote & vs macro_rules!

Max WellsMax WellsFounder of Rustify

TL;DR: A procedural macro is a Rust function that takes a TokenStream as input and returns a TokenStream as output; it transforms or generates code at compile time. There are three kinds: #[derive(MyTrait)] (adds trait impls), #[my_attribute] (transforms items), and my_macro!(...) (function-like, expands anywhere). Proc-macros live in their own crate with proc-macro = true in Cargo.toml. Use syn to parse input and quote! to generate output. Proc-macros have been stable since Rust 1.15 (derive) and Rust 1.45 (attribute and function-like), and are the backbone of nearly every major Rust framework in 2026.


What Are the Three Types of Proc-Macros?

Derive macros add trait implementations. Attribute macros transform items. Function-like macros expand like declarative macros.

// 1. Derive macro; adds impl block for a struct/enum
#[derive(Serialize, Deserialize, Debug)]
struct User { name: String, age: u32 }
 
// 2. Attribute macro; transforms the annotated item
#[route(GET, "/users")]
async fn get_users() -> impl Responder { ... }
 
// 3. Function-like macro; called with macro syntax
let query = sql!(SELECT * FROM users WHERE id = $1);

#[derive(...)] is by far the most common; serde, thiserror, clap, and diesel all use it.


How Do You Create a Proc-Macro Crate?

Proc-macros must live in a separate crate with proc-macro = true set in Cargo.toml.

# my-derive/Cargo.toml
[lib]
proc-macro = true
 
[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"
// my-derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
 
#[proc_macro_derive(MyTrait)]
pub fn my_trait_derive(input: TokenStream) -> TokenStream {
    let ast = parse_macro_input!(input as DeriveInput);
    let name = &ast.ident;
    quote! {
        impl MyTrait for #name {
            fn hello(&self) -> &str { stringify!(#name) }
        }
    }.into()
}

How Do syn and quote Work Together?

syn parses a TokenStream into a Rust AST. quote! turns Rust syntax back into a TokenStream.

  • syn::parse_macro_input!(input as DeriveInput): parse the struct/enum the macro is applied to
  • syn::DeriveInput: gives you the name, generics, and fields
  • quote! { ... }: write Rust code with #interpolations like a template
  • #name, #(#fields),*: interpolate parsed values into generated code

Together they form the standard proc-macro toolkit: parse with syn; generate with quote.


Proc-Macro vs macro_rules! in 2026

macro_rules! does textual pattern matching. Proc-macros operate on token trees and have full Rust logic.

Featuremacro_rules!Proc-macro
PowerPattern matchingFull Rust code
AST access✅ via syn
Error messagesBasicPrecise spans via syn::Error
Separate crate❌ not required✅ Required
Compile-time costLowHigher (parses full AST)
MaintainerRust core team (built-in)Community (syn, quote by dtolnay)
Best forRepetition, simple patternsDerives, frameworks, codegen

If your macro only needs to repeat or substitute tokens, choose macro_rules! (it is simpler and faster to compile). If you need to inspect struct fields, generate trait impls conditionally, or build a framework like Axum or Leptos, use a proc-macro. In 2026, proc-macros are the default choice for any crate that ships a #[derive(...)] or #[route(...)] annotation.


Frequently Asked Questions

Yes, but it is strongly discouraged. Proc-macros run at compile time on the developer's machine; any I/O must be deterministic and fast. Build scripts (build.rs) are the proper place for I/O-heavy codegen.

The Rust compiler links proc-macro crates as native libraries and loads them during compilation. This requires a clean separation from regular library code to avoid circular dependencies and to ensure the macro binary is compiled for the host platform, not the target.

proc-macro2 re-exports the proc_macro types in a form usable outside of a proc-macro context; enabling unit testing of proc-macro logic without invoking the compiler. Most proc-macro authors use proc-macro2::TokenStream internally and convert at the boundary.

Partially. Derive macros operate on the items passed to them and do not introduce new identifiers into the caller's scope by default. Attribute and function-like macros can be non-hygienic depending on how they generate code, so authors must be careful with generated identifier names.

serde (derive), thiserror (derive), tokio::main (attribute), clap (derive), sqlx::query! (function-like), leptos::component (attribute), and axum routing macros all rely on proc-macros. They are foundational to the Rust ecosystem.


Sources


  • macro: Overview of all macro types in Rust
  • macro-rules: Declarative macros as an alternative
  • derive: The #[derive(...)] attribute proc-macros power
  • trait: Derive proc-macros generate trait implementations
  • serde: The most widely used proc-macro crate in the ecosystem

Keep Reading

  • Learn Rust in 2026: Proc-macros are a mid-to-advanced Rust topic worth understanding after ownership
  • Rust vs Go in 2026: See how Rust's metaprogramming compares to Go's code generation approach

Ready to Land a $80-120k Rust Job?