Skip to content
← All articles

How #[derive(Debug)] works, and attribute macros demystified

Derive appends and cannot modify; attribute macros replace the whole item. Traced end to end, including what #[tokio::main] actually rewrites your main into.

#[derive(Debug)] is the first attribute every Rust beginner writes and the last one they understand. This article opens both it and its more alarming cousin, the attribute macro, because between them they account for most of the code in a typical Rust project that nobody typed.

First, a correction

The built-in derives are not procedural macros.

Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default are implemented inside rustc. They share the #[derive(...)] syntax with user-written derive macros, and they behave the same way from the outside, but they are compiler built-ins with direct access to internal representations — no syn, no quote, no separate crate.

That matters mostly for expectations: built-in derives are fast and always available, while a #[derive(Serialize)] costs you a dependency on syn and a compilation of that dependency.

What a derive does, conceptually

Four steps, and they are the same for the built-ins and for anything you write:

  1. Receive the item’s tokens. The whole struct or enum, verbatim.
  2. Parse them into a syntax tree. syn::parse_macro_input!(input as DeriveInput) gives a DeriveInput with the name, the generics, the visibility, and the Data — a struct’s fields or an enum’s variants.
  3. Iterate. For Debug on a struct, that means walking the fields and producing one .field("name", &self.name) call per field.
  4. Emit an impl.

Written out for a simple struct, #[derive(Debug)] on

#[derive(Debug)]
pub struct Point { x: f64, y: f64 }

is equivalent to roughly

impl ::core::fmt::Debug for Point {
    fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
        f.debug_struct("Point")
            .field("x", &self.x)
            .field("y", &self.y)
            .finish()
    }
}

debug_struct / field / finish is a real public API — it is what gives you the {:#?} pretty-printed form for free, and you can call it by hand when you want a custom Debug that still looks standard.

The output is appended next to the struct (18.17). The struct itself is untouched, which is why a derive can never add a field or change a type.

The generics problem, and why hand-rolled derives break

This is the part that separates a toy derive from a correct one.

#[derive(Debug)]
struct Wrapper<T> { inner: T }

The generated impl cannot be impl<T> Debug for Wrapper<T>, because printing inner requires T: Debug. It must be:

impl<T: ::core::fmt::Debug> ::core::fmt::Debug for Wrapper<T> { ... }

Adding that bound for every type parameter is what syn gives you helpers for, and forgetting it is the number-one bug in hand-written derive macros: the macro works perfectly on every concrete type in your test suite and fails the moment someone uses it on a generic.

It also has a well-known downside, which item 7.24 covered from the other direction: the derived bound is T: Debug on every parameter, whether or not that parameter actually appears in a field. struct Marker<T> { data: u32, _p: PhantomData<T> } gets T: Debug it does not need, and then Marker<NotDebug> mysteriously stops being printable. Crates that care about this (serde with #[serde(bound = "...")], derivative) let you override the bound explicitly. The built-in derives do not.

Attribute macros: whole-item rewriters

A derive appends. An attribute macro replaces, and the replacement need bear no resemblance to what you wrote.

The clearest example in the ecosystem:

#[tokio::main]
async fn main() {
    do_something().await;
}

main is declared async. But main cannot be async — the language has no async runtime, and the entry point must be a synchronous function. So what runs?

#[tokio::main] receives the entire async fn main as tokens and returns something like:

fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {
            do_something().await;
        })
}

The async is gone. The signature changed. The body has been moved inside a closure passed to block_on. The item was replaced. Nothing about the source you wrote survives except the body, and even that got relocated.

Arguments arrive as the first TokenStream:

#[tokio::main(flavor = "current_thread")]

hands flavor = "current_thread" to the macro, which parses it and picks new_current_thread() instead. There is no type checking on those arguments beyond whatever the macro implements — a typo gives you whatever error the macro author chose to emit.

Since this course teaches async with a hand-rolled std-only executor, this is worth dwelling on: #[tokio::main] is a source rewrite, not a language feature. Async in Rust needs no compiler magic beyond async/await desugaring; the runtime is a library, and the attribute is a convenience that saves you eight lines.

#[test] is different again

#[test]
fn it_works() { assert_eq!(2 + 2, 4); }

#[test] is not a proc macro. It is a built-in attribute that the compiler recognises, collects during compilation, and uses to generate a test harness main containing a registry of every annotated function. That is why #[test] functions can be scattered across modules and still all run: something with a whole-crate view is collecting them, which no proc macro could do (18.16, point 6 — no state across expansions).

#[cfg(test)], #[derive], #[repr], #[inline] are similarly built in. Distinguishing “compiler feature” from “library-provided macro” is worth doing when you are trying to work out where some behaviour comes from.

Ordering when you stack them

#[my_attribute]
#[derive(Debug, Clone)]
struct Thing { ... }

Attribute macros run before derives, and the attribute sees the #[derive] in its input token stream. So #[my_attribute] could remove the derive, add another one, or rewrite the struct such that the derive then applies to something different from what you wrote.

Swap the order and you get a different program. This is a real source of confusing behaviour with attribute macros that manipulate fields, and it is why crates in that space document their required attribute order.

Reading generated code is the actual job

Almost nobody writes proc macros. Everybody reads their output — in a stack trace, in a cargo expand dump, in an error message pointing at a line that does not exist. The useful skills are:

  • Knowing whether an annotation could have changed the item (derive: no; attribute: yes).
  • Recognising #name interpolation and syn type names when you open the source of a macro you depend on.
  • Expecting the generics bound problem when a derive works on Foo<u32> but not on Foo<T>.
  • Reaching for cargo expand early rather than guessing.