Addressing the rumors There have been rumors going around, in the Reddit thread for facet , my take on reflection in Rust, which happened a bit too early, but here we are, cat’s out of the bag, let’s talk about it! Rumors that I, podcaster/youtuber fasterthanlime, want to kill serde , serialization / deserialization framework loved by many and which contributed greatly to Rust’s success, and I jus
AW
Amos Wengerfasterthanli.me
·May 29, 2025·Rust
The Cost of Convenience
Rust's declarative macros work well for pattern-matched syntax, but they struggle with anything that doesn't fit a rigid template. A simple visibility modifier breaks them:
print_fn_name!{pubfnmain(){println!("Hello, world!")}}ouroboros on main[!] via 🦀 v1.86.0❯ cargo c
Checking ouroboros v0.1.0 (/Users/amos/bearcove/ouroboros)
error: no rules expected keyword `pub`--> src/main.rs:11:5
|1| macro_rules! print_fn_name {
|--------------------------when calling this macro...11| pub fn main() {
|^^^no rules expected this token in macro call|note: while trying to match keyword `fn`
--> src/main.rs:2:6
|2| (fn $name:ident ($($args:tt),*) { $($body:tt)* }) => {
|^^
✂️
The fix is straightforward—until the next piece of syntax comes along. Procedural macros with syn solve this by parsing entire Rust syntax trees, letting us handle arbitrary function bodies, visibility modifiers, attributes, and everything else without writing brittle matching logic.
// With syn, parsing a full function is trivial
use proc_macro::TokenStream;use quote::quote;use syn::parse_macro_input;#[proc_macro_attribute]pubfnprint_fn_name(_attr:TokenStream,item:TokenStream) -> TokenStream{let input: syn::ItemFn = parse_macro_input!(item);let fn_vis = &input.vis;let fn_sig = &input.sig;let fn_name = &input.sig.ident;let fn_block = &input.block;let expanded = quote!{
#fn_vis #fn_sig {println!("Function name: {}",stringify!(#fn_name));
#fn_block
}};TokenStream::from(expanded)}
But that convenience has a price. Adding syn as a dependency means adding dozens of transitive dependencies, and every proc macro crate that depends on it must compile all of them. For a project using multiple proc macros, this adds up quickly.
Build Times: The Real Picture
The build time gap between declarative and procedural macros is significant. During a cold build, that syn-based proc macro pulls in its entire dependency tree, and with limited parallelism in CI environments, the wait is palpable:
Benchmark 1: [email protected]
Time (mean ± σ): 2.768 s ± 0.030 s [User: 6.621 s, System: 0.837 s]
Range (min … max): 2.721 s … 2.816 s10 runsBenchmark 2: syn@2
Time (mean ± σ): 9.352 s ± 0.039 s [User: 29.859 s, System: 0.647 s]
Range (min … max): 9.287 s … 9.424 s10 runsBenchmark 3: syn@1
Time (mean ± σ): 9.302 s ± 0.031 s [User: 30.056 s, System: 0.742 s]
Range (min … max): 9.257 s … 9.346 s10 runsSummary[email protected] ran
3.36 ± 0.04 times faster than syn@13.38 ± 0.04 times faster than syn@2
On a local machine with full parallelism, an M4 Pro can chew through builds fast enough that the difference barely registers. But that's not the scenario most developers face. With -j1—a fair proxy for constrained CI environments—the build time jumps dramatically. And the gap grows even wider when you want optimized proc macros:
# Set the settings for build scripts and proc-macros.[profile.dev.build-override]opt-level=3ouroboros-family on main[✘+?]❯ ./hyperfine.sh
Benchmark 1: decl
Time (mean ± σ): 112.6 ms ± 2.1 ms [User: 93.6 ms, System: 81.9 ms]
Range (min … max): 110.4 ms … 117.1 ms10 runsBenchmark 2: syn
Time (mean ± σ): 4.140 s ± 0.070 s [User: 17.079 s, System: 0.647 s]
Range (min … max): 4.066 s … 4.315 s10 runsSummarydecl ran
36.76 ± 0.93 times faster than syn
Critics offer two common refrains. First, that proc macros are inherently expensive and that's simply the way things are. Second, that only cold builds suffer, and warm builds make the difference irrelevant.
The first argument deserves scrutiny. It's entirely possible to write proc macros with zero dependencies—just raw token streams:
// in `ouroboros-manual-macro/src/lib.rs`use proc_macro::{Delimiter,Group,Ident,Literal,Punct,Spacing,Span,TokenStream,TokenTree};#[proc_macro_attribute]pubfnprint_fn_name(_attr:TokenStream,item:TokenStream) -> TokenStream{letmut tokens = item.into_iter();letmut output = Vec::new();// 1. Pass through tokens until "fn"for token in&mut tokens {let is_fn = matches!(&token,TokenTree::Ident(ident)if ident.to_string() == "fn");
output.push(token.clone());if is_fn {break;}}// 2. Next must be the function name identifierlet fn_name_ident = match tokens.next(){Some(TokenTree::Ident(ident)) => ident,
_ => panic!("Expected function name after fn"),};let fn_name_str = fn_name_ident.to_string();
output.push(TokenTree::Ident(fn_name_ident.clone()));// 3. Pass through everything up to (and including) the function body { ... }for token in tokens {ifletTokenTree::Group(group) = &token {if group.delimiter() == Delimiter::Brace{
output.push(TokenTree::Group(Group::new(Delimiter::Brace,TokenStream::from_iter([TokenTree::Ident(Ident::new("println",Span::call_site())),TokenTree::Punct(Punct::new('!',Spacing::Alone)),TokenTree::Group(Group::new(Delimiter::Parenthesis,TokenStream::from_iter([TokenTree::Literal(Literal::string(&format!("Function name: {fn_name_str}"),))]),)),TokenTree::Punct(Punct::new(';',Spacing::Alone)),].into_iter().chain(group.stream()),),)));continue;}}
output.push(token);}
output.into_iter().collect()}
It works, and it's fast. The ergonomics, however, leave much to be desired. You lose structured parsing, type information, proper error messages, and all the conveniences that make proc macros pleasant to write.
The Middle Ground
What if there were something between hand-rolling token stream manipulation and pulling in the full syn machinery? A library that provides lightweight, composable parsers without requiring the entire Rust AST to be modeled in types?
This is where the core tension lies. syn models the entire Rust language—every expression, pattern, type, and statement. That's what makes it powerful but also what makes it heavy. The question is whether you need all of that, or whether a simpler approach to parsing—one built on lower-level operations—can give you the convenience you actually need at a fraction of the compile-time cost.
The unsynn approach
With unsynn, the parsing problem gets considerably simpler. The crate avoids the full Rust AST and instead works directly with the token stream, letting you define exactly what you need.
First, we bring in the crate and define the fn keyword we'll need:
use unsynn::*;keyword!{KFn = "fn";}
keyword! is a declarative macro; the curious can inspect its expansion:
Many matches one or more of the contained pattern, similar to + in regular expressions.
Cons sequences two things that follow each other.
Except performs a lookahead to confirm a token does not match; it consumes nothing but rejects a match.
KFn is our defined keyword — a bare fn, not a string literal. Custom keywords are allowed here too.
TokenTree captures an entire token tree — for instance, a parenthesized expression counts as one.
The point is we're not parsing what we don't need. We skip tokens until we hit fn, grab an identifier, skip to the body, and take the body.
Within unsynn!, structs represent sequences of things (like Cons<...> but with named fields); enums represent alternatives. Option<T> works as well. Defining these as named structs lets us implement quote::ToTokens on them:
Yes, quote is still in use, sharing the proc_macro2 dependency — a necessary evil since the proc_macro API is currently unavailable to non-proc-macro crates. A tracking issue exists to address this, with a PR awaiting adoption at the time of writing. Until then, an abstraction layer is required to write unit tests.
We start by converting the input TokenStream from the built-in proc_macro form to the proc_macro2 variant, then parse it into FunctionDecl. There is no error recovery here — the parsing logic is intentionally simple — but for our test function, it suffices.
After parsing, we destructure the fields so quote! can interpolate them, preserving span information. This only works for types implementing quote::ToTokens, which is why we need three more implementations:
These largely forward to existing implementations — unsynn provides its own ToTokens trait that behaves essentially the same. For comparison, see quote::ToTokens and unsynn::ToTokens.
Compile-time costs
The natural question: what do compile times look like? unsynn does less work than syn, but it's more practical than dealing with the raw proc_macro API. Cold build times tell part of the story:
ouroboros-family on main[!]❯ ./hyperfine.sh
Benchmark 1: decl
Time (mean ± σ): 166.2 ms ± 4.0 ms [User: 166.3 ms, System: 129.2 ms]
Range (min … max): 161.0 ms … 176.2 ms17 runsBenchmark 2: manual
Time (mean ± σ): 267.9 ms ± 3.0 ms [User: 299.8 ms, System: 283.6 ms]
Range (min … max): 263.6 ms … 273.1 ms10 runsBenchmark 3: syn,
Time (mean ± σ): 1.574 s ± 0.004 s [User: 2.185 s, System: 0.434 s]
Range (min … max): 1.567 s … 1.582 s10 runsBenchmark 4: unsynn
Time (mean ± σ): 718.0 ms ± 1.9 ms [User: 1032.1 ms, System: 473.3 ms]
Range (min … max): 714.3 ms … 721.5 ms10 runsSummarydecl ran
1.61 ± 0.04 times faster than manual 4.32 ± 0.10 times faster than unsynn 9.47 ± 0.23 times faster than syn,
Unquestionably lighter than syn — but it's also doing fewer things. That's the point.
Warm builds matter more
One could argue cold build times don't matter if caching is set up properly, with cargo-binstall handling prebuilt dependencies. So let's look at warm builds.
To make the point, the body of main now repeats this block a hundred times:
This is literal AI slop, generated with GPT-4.1 at increasing nesting depths. The results:
{fnprint_nested<T: std::fmt::Debug>(val:&T){println!("Nested value: {:?}", val);}letmut num = 42;
num += 9;let drizzle:bool = false;let _x = if drizzle {letmut extra = 100;for i in0..2{
extra += i;}
extra - 1}else{100};let cheese = "cheddar";let y:Vec<i32> = vec![2,4,8,16,32,64];for i in0..3{let doubles:Vec<_> = (0..=i).map(|j| j *2).collect();print_nested(&doubles);println!("banana{}", i);}let qwerty = ('a',3,"xyz",false,7.81);for _ in0..2{let _temp = 'z';let nest = Some(vec![_temp;2]);ifletSome(chars) = nest {for c in chars {print_nested(&c);}}}if num % 3 == 1{println!("Wobble!");}else{if num > 40{let check = Some(num *2);ifletSome(val) = check {print_nested(&val);}}}match cheese {"cheddar" => {println!("cheese type 1");let cheese_types = vec!["swiss","brie","cheddar"];for(i, c)in cheese_types.iter().enumerate(){if c == &cheese {print_nested(&i);}}},
_ => println!("other cheese")}let strange:Option<&str> = Some("ghost cat");ifletSome(ghost) = strange {println!("Boo says {}!", ghost);let deep = Some(Some(vec![ghost;1]));ifletSome(Some(v)) = deep {print_nested(&v);}}let prickle = [1,2,3,4,5];fnprint_vector<T: std::fmt::Display>(v:&[T]){for item in v {println!("{}", item);}}{print_vector(&prickle);}letmut llama = 0;while llama < 5{let condition = (llama % 2 == 0, llama >= 3);match condition {(true,true) => print_nested(&llama),(true,false) => (),(false, _) => (),}
llama += 1;}fntangerine<T:Default + Copy>() -> (T,i32){(T::default(),99)}let _wumpus = tangerine::<u8>();let _unused = &mut num;let nonsense = |a:&str,b:i32,c:i32| format!("Nonsense{}{}{}", a, b, c);println!("{}",nonsense(cheese, num, _x));let _ = format!("{}{}", drizzle, y.len());{let bubble = 2.71f64;println!("{}", bubble);let levels = vec![vec![bubble]];for l in&levels {for n in l {print_nested(n);}}}}ouroboros-family on main[!]❯ ./hyperfine.sh
Benchmark 1: decl
Time (mean ± σ): 197.7 ms ± 3.6 ms [User: 159.7 ms, System: 251.0 ms]
Range (min … max): 191.7 ms … 204.6 ms14 runsBenchmark 2: manual
Time (mean ± σ): 204.0 ms ± 7.6 ms [User: 166.8 ms, System: 241.9 ms]
Range (min … max): 195.2 ms … 228.4 ms14 runsBenchmark 3: syn,
Time (mean ± σ): 304.8 ms ± 3.1 ms [User: 267.2 ms, System: 249.2 ms]
Range (min … max): 299.8 ms … 310.5 ms10 runsBenchmark 4: unsynn
Time (mean ± σ): 208.6 ms ± 3.8 ms [User: 169.9 ms, System: 252.9 ms]
Range (min … max): 203.0 ms … 215.9 ms14 runsSummarydecl ran
1.03 ± 0.04 times faster than manual 1.05 ± 0.03 times faster than unsynn 1.54 ± 0.03 times faster than syn,
The measurement procedure: touch main.rs, then rerun cargo build. As of May 2025, cargo's checksum-freshness option remains unstable, so changing the file's modification time triggers a rebuild. Dependencies themselves aren't rebuilt — we're not paying to rebuild syn — but we are waiting for syn to parse the entire function body from a token stream, after the Rust compiler has already tokenized it and built its own AST over that same stream.
On this benchmark, roughly 200 milliseconds is the cost of parsing and compiling all that. The declarative macro is essentially free; invoking an already-built proc macro runs on the order of 10 milliseconds. Parsing those ~11,000 lines of AI slop takes syn about 100 milliseconds.
This is the real reason to be cautious with syn. It's a fascinating, wonderful tool for writing proc macros, but it doesn't let you do less — it always parses the full Rust AST, even when your needs are far more modest. syn being large-ish is acceptable; alternatives will over time grow and carry their own one-time compilation cost.
But proc macro invocations are currently not cached, and it's unclear whether they will be. Any proc macro parsing and generating substantial amounts of code does so on every compilation where cargo suspects something changed — even when nothing did. That's why minimally-worked proc macros matter, keeping both cold and warm compile times from ballooning the way they do with syn and serde today.
Impact at scale
Micro-projects demonstrate the cost, but does syn actually slow down larger builds? In the dependency tree of beardist, an internal tool, syn appears eight separate times:
beardist on main via 🦀 v1.86.0❯cargo tree -i syn --depth 1
syn v2.0.100
├── clap_derive v4.5.32 (proc-macro)
├── displaydoc v0.2.5 (proc-macro)
├── icu_provider_macros v1.5.0 (proc-macro)
├── serde_derive v1.0.219 (proc-macro)
├── synstructure v0.13.1
├── yoke-derive v0.7.5 (proc-macro)
├── zerofrom-derive v0.1.6 (proc-macro)
└── zerovec-derive v0.10.3 (proc-macro)
Removing syn from that tree would require replacing serialization, argument parsing, and dropping reqwest — which depends on syn through the url crate. That's enormous effort.
There's a trick, though: simulate making syn faster by running one build where every crate takes twice as long, then another where every crate exceptsyn takes twice as long. Between those builds, a virtual speedup emerges — a technique borrowed from the coz causal profiler.
Absolute timings aren't meaningful here, but we gain a magic checkbox: "Make this crate build twice as fast," revealing what would happen under cargo's actual scheduler.
Some findings from this exercise:
jiff, which accounts for a significant portion of the build time, is not on the critical path — speeding it up buys nothing on cold builds.
Making tokio build twice as fast yields little difference.
Even magically speeding up serde_derive has no measurable effect on this project.
Making syn twice as fast, however, compacts the entire build graph. Proc-macro dependencies like serde_derive and clap_derive shift left with their dependents, shaving a solid 10% off total build time.
Interestingly, the tooling to produce this graph compaction — this visualization of syn sitting on the critical path — was written only after the article was drafted, out of pure conviction.
Sometimes hubris pays off.
Reproducing the analysis
For patrons supporting at five euros per month or more, fargo is available in the extras section of the blog. This wrapper around cargo and rustc listens for artifact notifications, artificially delays them, and converts cargo's HTML timing files into JSON, ready for a Svelte 5 component that renders the visualizations shown above.
fargo runs fully offline, making it suitable for proprietary codebases — you can finally go to your boss and say, "I told you so." Author anecdotes aside, it's open source on GitHub.
Vercel welcomes the Gel Data team and deepens support for the Python ecosystem through PSF sponsorships, community funding, and improved Python developer tools.
Panics in Rust Workers were historically fatal, poisoning the entire instance. By collaborating upstream on the wasm‑bindgen project, Rust Workers now support resilient critical error recovery, including panic unwinding using WebAssembly Exception Handling.