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! { pub fn main() { 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] pub fn print_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 (minmax): 2.721 s2.816 s 10 runs Benchmark 2: syn@2 Time (mean ± σ): 9.352 s ± 0.039 s [User: 29.859 s, System: 0.647 s] Range (minmax): 9.287 s9.424 s 10 runs Benchmark 3: syn@1 Time (mean ± σ): 9.302 s ± 0.031 s [User: 30.056 s, System: 0.742 s] Range (minmax): 9.257 s9.346 s 10 runs Summary [email protected] ran 3.36 ± 0.04 times faster than syn@1 3.38 ± 0.04 times faster than syn@2
Cool bear

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 = 3
ouroboros-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 (minmax): 110.4 ms117.1 ms 10 runs Benchmark 2: syn Time (mean ± σ): 4.140 s ± 0.070 s [User: 17.079 s, System: 0.647 s] Range (minmax): 4.066 s 4.315 s 10 runs Summary decl 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] pub fn print_fn_name(_attr: TokenStream, item: TokenStream) -> TokenStream { let mut tokens = item.into_iter(); let mut 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 identifier let 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 { if let TokenTree::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"; }
Cool bear

keyword! is a declarative macro; the curious can inspect its expansion:

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] struct KFn; impl unsynn::Parser for KFn { fn parser(tokens: &mut unsynn::TokenIter) -> Result<Self> { use unsynn::Parse; unsynn::CachedIdent::parse_with(tokens, |ident, tokens| { if ident == "fn" { Ok(KFn) } else { unsynn::Error::other::<KFn>( tokens, alloc::__export::must_use({ let res = alloc::fmt::format(alloc::__export::format_args!( "keyword {:?} expected, got {:?} at {:?}", "fn", ident.as_str(), ident.span().start() )); res }), ) } }) } } impl unsynn::ToTokens for KFn { fn to_tokens(&self, tokens: &mut TokenStream) { unsynn::Ident::new("fn", unsynn::Span::call_site()).to_tokens(tokens); } } impl AsRef<str> for KFn { fn as_ref(&self) -> &str { &"fn" } }

Next, we declare what we want to parse inside the unsynn! macro:

unsynn! { struct UntilFn { items: Many<Cons<Except<KFn>, TokenTree>>, } struct UntilBody { items: Many<Cons<Except<BraceGroup>, TokenTree>>, } struct Body { items: BraceGroup, } struct FunctionDecl { until_fn: UntilFn, _fn: KFn, name: Ident, until_body: UntilBody, body: Body } }

Breaking down the combinators used here:

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

Amos

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:

Cool bear

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.

This explains the conversions in our entry point:

#[proc_macro_attribute] pub fn print_fn_name( _attr: proc_macro::TokenStream, item: proc_macro::TokenStream, ) -> proc_macro::TokenStream { let item = TokenStream::from(item); let mut i = item.to_token_iter(); let fdecl = i.parse::<FunctionDecl>().unwrap(); let FunctionDecl { until_fn, _fn, name, until_body, body, } = fdecl; let fmt_string = format!("Function name: {}", name); quote::quote! { #until_fn fn #name #until_body { println!(#fmt_string); #body } } .into() }

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:

impl quote::ToTokens for UntilFn { fn to_tokens(&self, tokens: &mut unsynn::TokenStream) { self.items.to_tokens(tokens) } } impl quote::ToTokens for UntilBody { fn to_tokens(&self, tokens: &mut unsynn::TokenStream) { self.items.to_tokens(tokens) } } impl quote::ToTokens for Body { fn to_tokens(&self, tokens: &mut unsynn::TokenStream) { tokens.extend(self.items.0.stream()) } }

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 (minmax): 161.0 ms176.2 ms 17 runs Benchmark 2: manual Time (mean ± σ): 267.9 ms ± 3.0 ms [User: 299.8 ms, System: 283.6 ms] Range (minmax): 263.6 ms273.1 ms 10 runs Benchmark 3: syn, Time (mean ± σ): 1.574 s ± 0.004 s [User: 2.185 s, System: 0.434 s] Range (minmax): 1.567 s 1.582 s 10 runs Benchmark 4: unsynn Time (mean ± σ): 718.0 ms ± 1.9 ms [User: 1032.1 ms, System: 473.3 ms] Range (minmax): 714.3 ms721.5 ms 10 runs Summary decl 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:

Amos

This is literal AI slop, generated with GPT-4.1 at increasing nesting depths. The results:

{ fn print_nested<T: std::fmt::Debug>(val: &T) { println!("Nested value: {:?}", val); } let mut num = 42; num += 9; let drizzle: bool = false; let _x = if drizzle { let mut extra = 100; for i in 0..2 { extra += i; } extra - 1 } else { 100 } ; let cheese = "cheddar"; let y: Vec<i32> = vec![2,4,8,16,32,64]; for i in 0..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 _ in 0..2 { let _temp = 'z'; let nest = Some(vec![_temp; 2]); if let Some(chars) = nest { for c in chars { print_nested(&c); } } } if num % 3 == 1 { println!("Wobble!"); } else { if num > 40 { let check = Some(num * 2); if let Some(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"); if let Some(ghost) = strange { println!("Boo says {}!", ghost); let deep = Some(Some(vec![ghost; 1])); if let Some(Some(v)) = deep { print_nested(&v); } } let prickle = [1,2,3,4,5]; fn print_vector<T: std::fmt::Display>(v: &[T]) { for item in v { println!("{}", item); } } { print_vector(&prickle); } let mut llama = 0; while llama < 5 { let condition = (llama % 2 == 0, llama >= 3); match condition { (true, true) => print_nested(&llama), (true, false) => (), (false, _) => (), } llama += 1; } fn tangerine<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 (minmax): 191.7 ms204.6 ms 14 runs Benchmark 2: manual Time (mean ± σ): 204.0 ms ± 7.6 ms [User: 166.8 ms, System: 241.9 ms] Range (minmax): 195.2 ms228.4 ms 14 runs Benchmark 3: syn, Time (mean ± σ): 304.8 ms ± 3.1 ms [User: 267.2 ms, System: 249.2 ms] Range (minmax): 299.8 ms310.5 ms 10 runs Benchmark 4: unsynn Time (mean ± σ): 208.6 ms ± 3.8 ms [User: 169.9 ms, System: 252.9 ms] Range (minmax): 203.0 ms215.9 ms 14 runs Summary decl 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 except syn takes twice as long. Between those builds, a virtual speedup emerges — a technique borrowed from the coz causal profiler.

A figure from the coz paper that shows a conventional profile and a causal profile for a program. The conventional profile shows how much time is spent in each function, while the causal profile shows that making line 2 (`a()`) faster would improve performance by around 4.5% and optimizing line 5 (`b()`) would have no effect on performance.
[PDF] COZ: Finding Code that Counts with Causal Profiling

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.

Amos

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.

Cool bear Amos

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.