How to write attribute macros

Write attribute macros by defining a function with #[proc_macro_attribute] in a proc-macro crate to transform code at compile time.

When decoration becomes transformation

You are building a web server. You have fifty route handlers. Every single one needs to verify an admin token before executing the business logic. Copying the check into every function creates a maintenance nightmare. Changing the verification strategy later means editing fifty files. You could write a macro that declares "this function requires admin" and automatically injects the check at compile time. That is an attribute macro. It attaches metadata to code and transforms that code before the compiler type-checks it. Attribute macros turn repetitive boilerplate into clean declarations.

The contract: code in, code out

Rust provides three kinds of procedural macros. Attribute macros are the ones that look like #[...] and decorate an item. They run as ordinary functions during compilation. The compiler invokes your macro, passes it the attribute text and the decorated item text, and expects a new stream of tokens back. The compiler then replaces the original item with whatever you return.

Think of it like a stamp machine on an assembly line. The raw metal part arrives. The machine reads the label. If the label says "Paint Red", the machine sprays paint and outputs the finished part. The rest of the factory only sees the painted part. The stamping happens before assembly.

The macro is a function. It takes code, returns code. That is the entire contract. You do not need to understand compiler internals. You just need to parse the input, modify it, and emit valid Rust code. Treat the macro as a pure transformation step. If the input is valid Rust, the output must be valid Rust.

Minimal example: renaming a function

Attribute macros live in a separate crate. The crate must declare proc-macro = true in its Cargo.toml. This tells Cargo to compile the crate as a macro library rather than a standard library.

Here is the smallest possible case: a macro that appends _wrapper to a function name.

// In a crate with `proc-macro = true` in Cargo.toml
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};

// Marks this function as an attribute macro.
// Receives two TokenStreams: attribute content and the item.
#[proc_macro_attribute]
pub fn rename_wrapper(_attr: TokenStream, item: TokenStream) -> TokenStream {
    // Parse the raw tokens into a structured AST node.
    // syn converts token soup into a manipulatable object.
    let mut func = parse_macro_input!(item as ItemFn);

    // Build a new identifier string from the original name.
    // We keep the original span for accurate compiler error locations.
    let new_name = format!("{}_wrapper", func.sig.ident);
    func.sig.ident = syn::Ident::new(&new_name, func.sig.ident.span());

    // Serialize the modified AST back into a token stream.
    // The compiler will replace the original function with this output.
    quote! { #func }.into()
}

Apply it in your application crate:

use rename_crate::rename_wrapper;

#[rename_wrapper]
fn hello() {
    println!("Hello!");
}

fn main() {
    // The function is now named hello_wrapper.
    hello_wrapper();
}

The compiler never sees the original name. It only sees what the macro returns. Trust the expansion step. The rest of your codebase interacts with the transformed tokens.

What happens at compile time

When the compiler encounters #[rename_wrapper] fn hello() {}, it pauses. It invokes your macro function. It passes two TokenStream values. The first is _attr. Since you wrote #[rename_wrapper] with no arguments, this stream is empty. The second is item, which contains the raw tokens for fn hello() { ... }.

Your macro parses item into an ItemFn using syn. syn is the standard library for parsing Rust code into an abstract syntax tree. It turns the token soup into a structured object you can manipulate. You change the identifier. You use quote to turn the modified object back into tokens. quote is the standard way to generate code. It lets you write Rust-like syntax in the macro and interpolate variables using #.

The macro returns the new token stream. The compiler takes that stream and continues. It type-checks the renamed function. If the macro generates invalid code, the compiler rejects it.

Macros run before type checking. If your macro generates invalid code, the error points to the macro output, not the macro definition. This means debugging macro errors requires reading the expanded code. Run cargo expand to see exactly what the compiler receives.

Realistic example: injecting a guard

Renaming functions is a toy example. Real attribute macros usually inject logic. Here is a macro that enforces an admin check. It wraps the function body with a guard.

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

#[proc_macro_attribute]
pub fn ensure_admin(_attr: TokenStream, item: TokenStream) -> TokenStream {
    // Parse the function to access its body and signature.
    let mut func = parse_macro_input!(item as ItemFn);

    // Capture the original body so we can reuse it.
    let original_body = &func.block;
    let fn_name = &func.sig.ident;

    // Inject a check at the start of the function.
    // We wrap it in a block to avoid scoping conflicts.
    func.block = quote! {
        {
            if !is_admin() {
                panic!("Function {} requires admin privileges", #fn_name);
            }
        }
        #original_body
    };

    // Return the modified function.
    quote! { #func }.into()
}

Usage in the app:

use admin_crate::ensure_admin;

#[ensure_admin]
fn delete_database() {
    println!("Deleting everything...");
}

Keep the injected logic small. If the macro is doing too much, you are hiding complexity where the compiler cannot help you. Macros should automate patterns, not replace architectural decisions.

Reading attribute arguments

Attributes often carry configuration. You might write #[log(level = "debug")] or #[route(path = "/api/users")]. The _attr parameter holds this text. You can parse it just like you parsed the item.

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

#[proc_macro_attribute]
pub fn log_entry(attr: TokenStream, item: TokenStream) -> TokenStream {
    // Parse the attribute arguments into a Meta node.
    // Meta handles key-value pairs, lists, and bare identifiers.
    let meta = parse_macro_input!(attr as Meta);

    // Extract the level string, defaulting to "info" if missing.
    let level = match &meta {
        Meta::NameValue(nv) => match &nv.lit {
            syn::Lit::Str(s) => s.value(),
            _ => panic!("Expected a string literal for level"),
        },
        _ => "info".to_string(),
    };

    let mut func = parse_macro_input!(item as ItemFn);
    let fn_name = &func.sig.ident;
    let original_body = &func.block;

    // Inject logging based on the parsed level.
    func.block = quote! {
        {
            eprintln!("[{}] Entering {}", #level, #fn_name);
        }
        #original_body
    };

    quote! { #func }.into()
}

Parse the attribute into a custom struct instead of matching Meta manually. It makes your macro API stable and your code readable. The community convention is to define a syn::parse-compatible struct for anything beyond a single flag.

Pitfalls and compiler errors

Attribute macros introduce new failure modes. The most common mistake is forgetting to return the item. If your macro returns an empty token stream, the function disappears. Callers get E0425 (cannot find value). Always return the item, even if you did not modify it.

Another trap is applying the macro to the wrong type. If you parse as ItemFn but the user puts #[ensure_admin] on a struct, syn panics. The panic produces a cryptic error. Use syn::Error to return a clear message.

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

#[proc_macro_attribute]
pub fn safe_attr(_attr: TokenStream, item: TokenStream) -> TokenStream {
    // Attempt to parse the input as a function.
    // syn::parse returns a Result instead of panicking.
    let func = match syn::parse::<ItemFn>(item) {
        Ok(f) => f,
        Err(e) => return Error::new(
            e.span(),
            "This attribute can only be applied to functions"
        ).to_compile_error().into(),
    };

    // Proceed with transformation...
    quote! { #func }.into()
}

Treat macro errors as user errors. If the macro fails, the user needs to know why, not just see a parse error. Return syn::Error with a precise span and a helpful message.

Hygiene is another gotcha. Procedural macros are not hygienic. They inject tokens into the caller's scope. This means you can use names from the caller's scope. It also means you can accidentally shadow names. If your macro injects let x = 5; and the caller already has an x, you break their code. Convention is to use fully qualified paths for helpers inside macros to avoid collisions. Write std::panic::panic_any instead of panic_any if you are unsure. Keep injected identifiers predictable.

Decision: which macro type fits

Rust offers multiple macro types. Pick the one that matches your problem.

Use attribute macros when you need to wrap or modify existing items like functions, structs, or modules. Use derive macros when you want to generate trait implementations for a type definition. Use function-like macros when you need a macro invocation that looks like a function call, such as log!(...). Reach for declarative macros when you are writing simple, local patterns and do not need the full power of procedural parsing.

Pick the macro type that matches the shape of the problem. Do not use a sledgehammer to crack a nut.

Where to go next