I used #[derive(Serialize)] for two years before I actually looked at what it generates. When I finally ran cargo expand on a struct with five fields, I got 150 lines of serialization code — visitor patterns, generic bounds, field-by-field traversal, error handling. All generated from a single line. Understanding how production crates use macros changed how I think about API design. These aren’t academic exercises — they’re the patterns behind the most downloaded crates in the ecosystem.
serde: The Gold Standard
serde is the most depended-upon crate in Rust’s ecosystem. Its macro system is also one of the most sophisticated. Let’s unpack what #[derive(Serialize)] actually does.
What Gets Generated
Given this struct:
use serde::Serialize;
#[derive(Serialize)]
struct User {
name: String,
#[serde(rename = "user_age")]
age: u32,
#[serde(skip_serializing_if = "Option::is_none")]
email: Option<String>,
}
The derive macro generates an impl Serialize for User that:
- Calls
serializer.serialize_struct("User", N)where N is the number of non-skipped fields - For each field, calls
state.serialize_field("field_name", &self.field) - Respects attributes —
renamechanges the serialized field name,skip_serializing_ifadds a conditional check - Handles
Optionfields, default values, flattening, and dozens of other configurations
The expanded code looks roughly like this (simplified):
impl serde::Serialize for User {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
// Count non-skipped fields at runtime
let mut field_count = 2; // name and age are always present
if self.email.is_some() {
field_count += 1;
}
let mut state = serializer.serialize_struct("User", field_count)?;
state.serialize_field("name", &self.name)?;
state.serialize_field("user_age", &self.age)?; // renamed!
if let Some(ref value) = self.email {
state.serialize_field("email", value)?;
}
state.end()
}
}
How serde’s Macro Architecture Works
serde uses a clever two-crate pattern:
serde— the core crate with theSerialize/Deserializetraitsserde_derive— the proc-macro crate with the derive implementations
When you write #[derive(Serialize)], Rust looks for the proc macro. serde re-exports it using #[cfg_attr]:
// In serde's lib.rs
#[cfg(feature = "derive")]
#[allow(unused_imports)]
#[macro_use]
extern crate serde_derive;
#[cfg(feature = "derive")]
pub use serde_derive::*;
This means users only depend on serde with features = ["derive"] — they don’t need to know about serde_derive at all.
The Attribute System
serde supports over 50 container-level and field-level attributes. The macro parses these from the field’s attrs in syn:
// Simplified version of how serde parses attributes
fn parse_serde_attrs(attrs: &[Attribute]) -> FieldConfig {
let mut config = FieldConfig::default();
for attr in attrs {
if attr.path().is_ident("serde") {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("rename") {
let value = meta.value()?;
let s: LitStr = value.parse()?;
config.rename = Some(s.value());
} else if meta.path.is_ident("skip") {
config.skip = true;
} else if meta.path.is_ident("default") {
config.has_default = true;
}
// ... dozens more
Ok(())
})?;
}
}
config
}
The key insight: serde’s macro doesn’t just generate code — it maintains a rich intermediate representation of the struct’s serialization behavior, configured through attributes, and then generates the appropriate code from that representation. The parsing and generation phases are cleanly separated.
Lessons from serde
- Rich attribute DSL. Don’t make users write code when an attribute will do.
#[serde(rename = "name")]is cleaner than implementing a trait method. - Separate crate for the derive. The
serde+serde_derivesplit is the standard pattern. It lets the core crate be light and fast to compile when macros aren’t needed. - Exhaustive documentation. serde documents every attribute with examples. If your macro has configuration options, document them thoroughly.
clap: Declarative CLI Parsing
clap takes a different approach — it uses derive macros to turn a struct into a CLI argument parser.
What It Generates
use clap::Parser;
#[derive(Parser, Debug)]
#[command(name = "myapp", about = "A demo application")]
struct Args {
/// The name to greet
#[arg(short, long)]
name: String,
/// Number of times to greet
#[arg(short, long, default_value_t = 1)]
count: u32,
/// Whether to use uppercase
#[arg(long)]
uppercase: bool,
}
fn main() {
let args = Args::parse();
for _ in 0..args.count {
if args.uppercase {
println!("HELLO, {}!", args.name.to_uppercase());
} else {
println!("Hello, {}!", args.name);
}
}
}
The #[derive(Parser)] macro generates:
- An
impl clap::FromArgMatches for Argsthat extracts values from parsed CLI arguments - An
impl clap::Args for Argsthat defines the arguments - An
impl clap::CommandFactory for Argsthat builds the clapCommandwith all the metadata - The
parse()method onArgsthat ties it all together
clap’s Attribute Pattern
clap demonstrates a different attribute approach from serde. It uses multiple attribute namespaces:
#[command(...)]— container-level settings (app name, version, about)#[arg(...)]— field-level settings (short, long, default, help)#[group(...)]— argument grouping
Each namespace has its own set of valid keys. The macro validates these at compile time — use an invalid key and you get a clear error.
#[derive(Parser)]
struct Config {
/// Database connection URL
#[arg(long, env = "DATABASE_URL")]
database_url: String,
/// Server port
#[arg(short = 'p', long, default_value_t = 8080, value_parser = clap::value_parser!(u16).range(1..))]
port: u16,
/// Log level
#[arg(long, value_enum, default_value_t = LogLevel::Info)]
log_level: LogLevel,
}
#[derive(Clone, Debug, clap::ValueEnum)]
enum LogLevel {
Debug,
Info,
Warn,
Error,
}
Lessons from clap
- Use doc comments as documentation. clap uses
///doc comments as help text for CLI arguments. This is brilliant — the same comments that help developers reading the code also appear in--helpoutput. - Separate namespaces for attributes. Instead of cramming everything into
#[clap(...)], clap uses#[command],#[arg], and#[group]. This makes each attribute’s purpose clear. - ValueEnum derive for enums. clap provides a separate derive macro that turns an enum into valid CLI values. Composing multiple small derive macros is cleaner than one monolithic macro.
sqlx: Compile-Time SQL
sqlx does something that still amazes me every time I think about it. Its query! macro connects to your actual database during compilation to validate SQL and generate type-safe code.
How query! Works
let users = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE age > $1",
21
)
.fetch_all(&pool)
.await?;
At compile time, this macro:
- Connects to the database specified by
DATABASE_URLenvironment variable - Sends the query to the database’s
PREPAREendpoint - Gets back the parameter types and result column types
- Verifies that
$1(the parameter) matches the type of21 - Verifies that the
Userstruct has fields matching the result columns - Generates a type-safe query execution function
If you mistype a column name, you get a compile error, not a runtime error. If the column types don’t match your struct fields, you get a compile error.
The Offline Mode
Running a database during compilation isn’t always practical (CI, offline development). sqlx solves this with “offline mode”:
cargo sqlx prepare
This runs all your queries, caches the type information in a JSON file (.sqlx/), and subsequent builds use the cached data instead of a live connection.
// .sqlx/query-a1b2c3d4.json
{
"query": "SELECT id, name, email FROM users WHERE age > $1",
"describe": {
"columns": [
{ "name": "id", "type_info": "INT4" },
{ "name": "name", "type_info": "TEXT" },
{ "name": "email", "type_info": "TEXT" }
],
"parameters": ["INT4"]
}
}
Lessons from sqlx
- Compile-time external validation is possible — and sometimes worth the complexity. Catching SQL errors before your code runs saves hours of debugging.
- Provide fallback modes. The offline mode makes the macro practical in environments where a database isn’t available.
- Function-like macros for DSLs. SQL isn’t Rust syntax, so a function-like macro is the right choice. The input gets parsed by the macro, not by the Rust parser.
thiserror and anyhow: Error Macros
thiserror uses derive macros to generate Display and Error implementations:
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("not found: {name}")]
NotFound { name: String },
#[error("unauthorized")]
Unauthorized,
}
The #[error("...")] attribute uses a format-string-like syntax. The macro parses it, extracts the interpolation points ({0}, {name}), and generates the corresponding Display implementation:
// Generated (simplified)
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::Database(e) => write!(f, "database error: {}", e),
AppError::NotFound { name } => write!(f, "not found: {}", name),
AppError::Unauthorized => write!(f, "unauthorized"),
}
}
}
The #[from] attribute generates a From implementation, so sqlx::Error automatically converts to AppError::Database.
Pattern: Mini-Language in Attributes
thiserror’s #[error("...")] is a pattern you see across the ecosystem — a mini-language embedded in a string literal attribute. The derive macro parses the string at compile time and generates code based on its contents.
Other examples:
#[serde(deserialize_with = "...")]— function path in a string#[clap(value_parser = ...)]— expression in an attribute#[sqlx(rename_all = "snake_case")]— enum value in a string
tokio: The #[tokio::main] Attribute
#[tokio::main]
async fn main() {
// async code here
}
This attribute macro transforms your async main function into:
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
// your async code here
})
}
It takes your async function body, wraps it in a runtime builder, and produces a synchronous main that drives the runtime. Simple concept, but it eliminates the boilerplate that every async Rust program would otherwise need.
Patterns Worth Stealing
After studying these crates, here are the patterns I use in my own macros:
The trait + derive crate split. Define your trait in crate A, the derive macro in crate A-derive, and re-export from A. Users see one dependency.
Attribute-driven configuration. Instead of complex macro syntax, use attributes on fields and items. They’re familiar to Rust developers and well-supported by IDEs.
Error messages as a feature. Invest time in producing clear, well-spanned error messages. A macro with bad error messages is worse than no macro — users will waste time debugging the tool instead of their code.
Compile-time validation. If you can check something at compile time, do it. This is the entire point of macros — moving work from runtime to compile time.
Documentation with examples. Every public macro attribute should have a doc comment with a working example. Users learn by copying, and if your examples don’t compile, nobody will use your macro.
Next and final lesson: macro anti-patterns. When not to reach for macros, and the mistakes I’ve seen (and made) that turned macro-heavy codebases into maintenance nightmares.