How to Write an Attribute Macro in Rust

An attribute macro in Rust is a procedural macro that transforms an item based on a custom attribute, defined using the proc_macro crate.

When boilerplate becomes a bottleneck

You are building a game engine. You have dozens of Component structs. Every single one needs to implement Serialize, Debug, and a custom Component trait that returns a type ID. Writing that by hand is tedious. You copy-paste, make a typo, and spend twenty minutes hunting a mismatched field name. You wish you could slap a #[derive_component] on the struct and have the compiler write the glue code for you.

That is exactly what an attribute macro does. It lets you attach a custom attribute to a function, struct, or module, and the compiler runs your code to transform that item before compiling the rest of the program. You define the transformation once, and the compiler applies it everywhere you use the attribute.

What an attribute macro actually is

An attribute macro is a procedural macro that takes an item and an attribute as input, and returns a modified item. The name comes from the syntax: you use it like an attribute, such as #[my_macro].

Think of it like a spell on a blueprint. The architect draws a room (the struct). The spell (the macro) reads the blueprint, adds a door where there wasn't one, and hands the modified blueprint to the construction crew (the compiler). The construction crew never sees the spell. They just see the final blueprint and build it.

The macro runs at compile time. It does not add runtime overhead. The transformed code becomes part of your binary, just as if you had typed it manually.

Macros operate on TokenStream. A TokenStream is not plain text. It is a sequence of tokens: identifiers, keywords, punctuation, and literals. This structure lets the compiler understand the code's syntax even before full parsing. You rarely manipulate TokenStream directly. The ecosystem provides syn to parse tokens into a data structure and quote to generate tokens from Rust-like syntax. Using these crates is the community standard. Writing your own parser is error-prone and reinvents the wheel.

Setting up the macro crate

Macros live in their own crate. This separation keeps the macro code isolated from your application logic and allows the compiler to load the macro dynamically.

Create a new crate and set the crate type to proc-macro. This tells rustc to compile the crate as a dynamic library that exports macro functions.

[lib]
proc-macro = true

[dependencies]
syn = { version = "2.0", features = ["full"] }
quote = "1.0"

The syn crate parses the input tokens into a structured representation. The full feature enables parsing for all Rust syntax, which is necessary for most attribute macros. The quote crate generates the output tokens.

Convention aside: Always use syn and quote. The raw proc_macro API is low-level and lacks the safety and convenience of the higher-level crates. The community expects macros to use this stack.

Minimal example: logging a function

This macro wraps a function body with a log statement. It demonstrates the core pattern: parse, transform, quote.

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};

/// Injects a log statement at the start of a function.
///
/// Usage:
/// #[log_entry]
/// fn my_function() { ... }
#[proc_macro_attribute]
pub fn log_entry(_attr: TokenStream, item: TokenStream) -> TokenStream {
    // Parse the input tokens into a structured ItemFn.
    // parse_macro_input! handles error conversion automatically.
    let input = parse_macro_input!(item as ItemFn);

    // Destructure the function to access its parts.
    // We need the attributes, visibility, signature, and body.
    let ItemFn { attrs, vis, sig, block } = input;

    // Generate the new function with a println at the top.
    // #(#attrs)* expands all attributes, preserving existing ones.
    // stringify!(#sig.ident) converts the function name to a string.
    let expanded = quote! {
        #(#attrs)*
        #vis #sig {
            eprintln!("[LOG] Entering {}", stringify!(#sig.ident));
            #block
        }
    };

    // Convert the generated tokens back to a TokenStream.
    TokenStream::from(expanded)
}

The _attr parameter holds the tokens inside the brackets, like #[log_entry("debug")]. If you do not use arguments, prefix with underscore to suppress the unused variable warning. The item parameter holds the code the attribute is attached to.

Run cargo build and the macro executes. The compiler sees the expanded code, not the macro call. If the macro fails, the compiler reports an error in the macro output, not in the macro source.

Trust the borrow checker here. Macros cannot borrow data across the expansion boundary. Every value you use inside quote! must come from the parsed input or be generated fresh.

How the compiler processes your macro

The compiler follows a strict sequence when it encounters an attribute macro.

First, the compiler parses the source file and finds the attribute. It extracts the attribute tokens and the item tokens.

Second, the compiler invokes the macro crate. It passes the tokens as TokenStream arguments to the function marked with #[proc_macro_attribute].

Third, your macro runs. It parses the input, performs logic, and generates new tokens. This happens before type checking. The macro can inspect syntax but cannot check types. If you need type information, you must rely on the compiler to reject invalid expansions later.

Fourth, the compiler replaces the original item with the returned TokenStream. It then continues parsing and type-checking the expanded code.

If the macro returns invalid syntax, the compiler reports a syntax error. If the expanded code has type errors, the compiler reports those as usual. The error messages point to the expanded code, which can be confusing. Use cargo expand to see the macro output and debug issues.

Macros run in a separate namespace. Identifiers inside the macro do not capture variables from the calling code. This property is called hygiene. It prevents accidental name collisions and makes macros safer. You can opt out of hygiene for specific tokens, but that is an advanced technique. Stick to the default behavior unless you have a specific reason.

Realistic example: implementing a trait

This macro implements a Component trait for any struct with named fields. It shows how to handle errors gracefully and inspect struct fields.

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemStruct, DataStruct, Fields};

/// Automatically implements the `Component` trait for a struct.
///
/// The struct must have named fields.
#[proc_macro_attribute]
pub fn impl_component(_attr: TokenStream, item: TokenStream) -> TokenStream {
    // Parse the input as a struct.
    let input = parse_macro_input!(item as ItemStruct);
    let name = &input.ident;

    // Verify the struct has named fields.
    // syn::Error::to_compile_error() returns a compile error instead of panicking.
    let fields = match &input.data {
        DataStruct { fields: Fields::Named(_), .. } => {},
        _ => {
            return syn::Error::new_spanned(
                &input,
                "#[impl_component] only supports structs with named fields",
            )
            .to_compile_error()
            .into();
        }
    };

    // Generate the struct and the trait implementation.
    // We reuse #input to preserve the original struct definition.
    let expanded = quote! {
        #input

        impl Component for #name {
            fn type_id(&self) -> u32 {
                // Compute a simple hash of the type name.
                use std::collections::hash_map::DefaultHasher;
                use std::hash::{Hash, Hasher};
                let mut hasher = DefaultHasher::new();
                stringify!(#name).hash(&mut hasher);
                hasher.finish() as u32
            }
        }
    };

    TokenStream::from(expanded)
}

The syn::Error::new_spanned method creates an error attached to a specific span. This gives the compiler a precise location for the error message. Returning to_compile_error() converts the error into tokens that the compiler rejects with a helpful message. Panicking inside a macro is acceptable during development, but returning a compile error provides a better user experience.

Convention aside: Always check for invalid input and return syn::Error::to_compile_error(). A macro that panics crashes the compiler process in some configurations. A compile error stops gracefully and points to the problem.

Treat the macro output as public API. If you change the generated code, you might break downstream code that depends on specific behavior. Document what the macro generates and keep changes backward-compatible when possible.

Pitfalls and compiler errors

Macros introduce unique failure modes. Understanding these helps you debug faster.

If you forget to add proc-macro = true to Cargo.toml, the compiler rejects the crate with a crate type error. The error message mentions that the crate is not a proc-macro crate. Fix the Cargo.toml and rebuild.

If you miss an import, the compiler reports E0433 (failed to resolve). This happens when you use quote or syn without importing them. Check your use statements.

If you generate invalid syntax, the compiler reports a syntax error in the expanded code. The error location might point to the macro call. Use cargo expand to see the generated code and identify the syntax issue.

Hygiene can cause surprises. If you generate code that references an identifier, the compiler looks for that identifier in the macro's namespace, not the caller's. If the identifier is not found, you get E0425 (cannot find value). To fix this, ensure the generated code imports what it needs or uses fully qualified paths.

Macros cannot return values. They must return a TokenStream. If you try to return a different type, the compiler rejects it with a type mismatch error. Stick to TokenStream or types that implement Into<TokenStream>, like the result of quote!.

Performance matters for large codebases. Macros run at compile time. If your macro does heavy computation, it slows down compilation. Keep macro logic simple. Offload complex work to build scripts or pre-generated code when possible.

When in doubt, expand. cargo expand shows you exactly what the compiler sees. It is the most powerful tool for debugging macros.

Choosing the right macro type

Rust offers several macro types. Pick the one that matches your use case.

Use an attribute macro when you need to wrap or modify an existing item, like adding a log statement to a function or implementing a trait for a struct.

Use a derive macro when you want to generate trait implementations for a struct or enum based on its fields, like #[derive(Debug)].

Use a function-like macro when you need a macro that looks like a function call, such as sql!(SELECT * FROM users).

Reach for declarative macros when the transformation is simple pattern matching and you want to avoid the overhead of a separate crate. Declarative macros are defined with macro_rules! and are ideal for small utilities.

Pick the macro type that matches the shape of the code you want to generate. The compiler will guide you with clear errors if you choose wrong.

Where to go next