Skip to content
← All Rust articles

Rust explainer

Read the code behind derives and attribute macros

About 4 min to read. Allow extra time to work through examples.

Compare a derived Debug implementation with a manual one, understand generic bounds, and see how attribute macros can transform an item.

This article uses Rust. You can make it your practice mode to browse related content across the site.

#[derive(Debug)] saves you from writing formatting code for a type. To understand a derive, start with the implementation it provides, then ask what requirements that implementation places on the fields.

Compare derived and manual formatting

This complete program prints the same field values using two implementations:

use std::fmt;

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

struct ManualPoint {
    x: i32,
    y: i32,
}

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

fn main() {
    let derived = Point { x: 2, y: 5 };
    let manual = ManualPoint { x: 2, y: 5 };
    assert_eq!(derived.x + derived.y, 7);
    println!("{derived:?}");
    println!("{manual:?}");
    println!("{manual:#?}");
}

The manual implementation is a readable model, not a promise about rustc's exact expansion. debug_struct, field and finish are public formatting helpers. They also support the alternate {:#?} layout. Debug formatting is intended for inspection; its exact output is not a stable serialization format. See the Debug documentation.

Built-in derives and custom derives

Rust implements built-in derives such as Debug inside the compiler. They do not run a user crate's syn parser. Custom derives operate on token streams from a struct, enum or union and emit additional items. A custom derive often uses syn to parse and quote to generate tokens, but neither library is required by the language.

A derive adds items alongside the original definition. It cannot replace that definition to add a field or change a field type. Its output is often a trait implementation, but custom derives can emit other items too.

Follow the bounds

For struct Wrapper<T> { inner: T }, formatting inner requires T: Debug. An implementation that prints that field therefore needs the bound.

Built-in derive can also add a bound that the fields do not require. For example, PhantomData<T> implements Debug even when T does not. A manual implementation can preserve that flexibility:

use std::fmt;
use std::marker::PhantomData;

struct Marker<T> {
    data: u32,
    marker: PhantomData<T>,
}

impl<T> fmt::Debug for Marker<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Marker")
            .field("data", &self.data)
            .field("marker", &self.marker)
            .finish()
    }
}

struct NotDebug;

fn main() {
    let value = Marker::<NotDebug> { data: 7, marker: PhantomData };
    println!("{value:?}");
}

Replacing this manual implementation with #[derive(Debug)] makes this example require NotDebug: Debug. When a generic type fails to compile, inspect the implementation's bounds instead of assuming its fields need all of them. The derive-bounds lesson explores this distinction further.

Attribute macros can replace an item

An attribute macro receives its arguments and the annotated item as separate token streams. Its output replaces the item. It can preserve, transform or remove that item, or generate several items in its place. The resulting Rust still has to compile. Arguments have macro-specific syntax; their meaning is defined by that macro.

For example, #[tokio::main] transforms an async function into a synchronous wrapper that constructs a runtime and drives an async block with block_on. The block produces a future; it is not a closure. The default runtime flavor is multithreaded, while flavor = "current_thread" selects a runtime driven by the current thread. Availability depends on Tokio's enabled features. See Tokio's examples and feature requirements.

This attribute can also annotate functions other than main. It creates a runtime when the transformed function is called, so applying it broadly is different from sharing one existing runtime.

Identify the kind of attribute

Not every #[...] is a procedural macro. #[test], #[cfg] and #[repr] are built-in attributes. Test functions participate in the compiler's test harness when compiled in test mode, for example with rustc --test.

When stacking attributes, follow the macro's documented placement rules. An attribute above a derive can see that derive in its item input and may preserve or remove it. Do not assume that swapping attributes always changes the program, or that unrelated macros compose safely in either order. The attribute reference describes expansion ordering; the procedural macro reference defines what each macro receives and emits.

Inspect an unfamiliar expansion

Start with the macro's documentation and the compiler diagnostic. If the generated implementation is still unclear, an expansion tool such as cargo expand can help. Treat expanded text as an inspection aid rather than source you can always paste back unchanged.

Look for generated methods, added bounds and changed signatures. Custom derives and attribute macros live in a separate proc-macro crate from their callers; the ordinary formatting examples above need no such crate.

step 18 of 28

Macros, FFI and Type-Driven Design

Up next Function-like proc macros and proc-macro hygiene