Skip to main content

Attribute Macros

An attribute macro is a procedural macro used like this:

#[my_attribute]
fn my_function() { ... }

or with arguments:

#[my_attribute(key = "value")]
fn my_function() { ... }

It:

  • Receives the annotated item as input.
  • Optionally receives arguments from the attribute.
  • Returns transformed Rust code that replaces the original item.

When to Use Attribute Macros

Use them when you want to:

  • Modify function behavior (logging, timing, retries, async setup).
  • Add validation or instrumentation.
  • Generate extra code around structs, modules, or impl blocks.
  • Create ergonomic APIs (e.g., #[tokio::main], #[test], #[wasm_bindgen]).

Attribute Macros vs Custom Derives

FeatureAttribute MacroCustom Derive
Syntax#[my_attr]#[derive(MyTrait)]
Applies toAny itemStructs/enums/unions
Can modify original item✅ Yes❌ No (only adds code)
Receives arguments✅ Yes❌ No

How Attribute Macros Work

  1. Compiler encounters #[my_attr(...)].
  2. It passes:
    • The attribute arguments as a TokenStream.
    • The annotated item as another TokenStream.
  3. Your macro:
    • Parses both.
    • Generates new code.
  4. The new code replaces the original item.

Setting Up an Attribute Macro Crate

Procedural macros must live in a proc-macro crate.

cargo new my_attr_macros --lib

Cargo.toml:

[lib]
proc-macro = true

[dependencies]
syn = "2"
quote = "1"
proc-macro2 = "1"

Example 1: Simple Logging Attribute for Functions

We’ll write #[log_calls] to log entry and exit of a function.

Macro Implementation

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

#[proc_macro_attribute]
pub fn log_calls(_attr: TokenStream, item: TokenStream) -> TokenStream {
// Parse the input as a function
let input = parse_macro_input!(item as ItemFn);

let vis = &input.vis;
let sig = &input.sig;
let block = &input.block;
let name = &sig.ident;

let expanded = quote! {
#vis #sig {
println!("Entering {}", stringify!(#name));
let result = (|| #block)();
println!("Exiting {}", stringify!(#name));
result
}
};

TokenStream::from(expanded)
}

Usage

use my_attr_macros::log_calls;

#[log_calls]
fn add(a: i32, b: i32) -> i32 {
a + b
}

fn main() {
let x = add(2, 3);
}

What Gets Generated

fn add(a: i32, b: i32) -> i32 {
println!("Entering add");
let result = (|| { a + b })();
println!("Exiting add");
result
}

This preserves return values, early returns, and ? behavior.

Example 2: Attribute Macro With Arguments

Let’s create:

#[route(method = "GET", path = "/users")]
fn get_users() { ... }

Macro Implementation

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

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
let input = parse_macro_input!(item as ItemFn);

let mut method = None;
let mut path = None;

let meta = parse_macro_input!(attr as syn::AttributeArgs);
for arg in meta {
if let NestedMeta::Meta(Meta::NameValue(nv)) = arg {
if nv.path.is_ident("method") {
if let Lit::Str(lit) = nv.lit {
method = Some(lit.value());
}
}
if nv.path.is_ident("path") {
if let Lit::Str(lit) = nv.lit {
path = Some(lit.value());
}
}
}
}

let method = method.expect("Missing method");
let path = path.expect("Missing path");

let name = &input.sig.ident;
let vis = &input.vis;
let sig = &input.sig;
let block = &input.block;

let expanded = quote! {
// original function
#vis #sig #block

// generated metadata
fn #name##_route_info() {
println!("Route: {} {}", #method, #path);
}
};

TokenStream::from(expanded)
}

Usage

use my_attr_macros::route;

#[route(method = "GET", path = "/users")]
fn get_users() {
println!("Fetching users");
}

Conceptual Expansion

fn get_users() {
println!("Fetching users");
}

fn get_users_route_info() {
println!("Route: GET /users");
}

Example 3: Attribute Macro on Structs

#[auto_new]
struct Point {
x: i32,
y: i32,
}

Generate a constructor:

Macro Implementation

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, Data, Fields};

#[proc_macro_attribute]
pub fn auto_new(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input = parse_macro_input!(item as DeriveInput);
let name = &input.ident;

let fields = match &input.data {
Data::Struct(data) => match &data.fields {
Fields::Named(fields) => &fields.named,
_ => panic!("auto_new only supports named fields"),
},
_ => panic!("auto_new only supports structs"),
};

let params = fields.iter().map(|f| {
let name = &f.ident;
let ty = &f.ty;
quote! { #name: #ty }
});

let inits = fields.iter().map(|f| {
let name = &f.ident;
quote! { #name }
});

let expanded = quote! {
#input

impl #name {
pub fn new(#(#params),*) -> Self {
Self { #(#inits),* }
}
}
};

TokenStream::from(expanded)
}

Usage

#[auto_new]
struct Point {
x: i32,
y: i32,
}

fn main() {
let p = Point::new(1, 2);
}

Expansion

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

impl Point {
pub fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
}

Hygiene and Name Resolution

Attribute macros are hygienic:

  • They won’t accidentally capture local variables.
  • Use stringify!, fully qualified paths (::std::println!), and careful naming.

Error Handling in Attribute Macros

Use compile-time errors instead of panic!:

return syn::Error::new_spanned(
input,
"This attribute only supports functions",
)
.to_compile_error()
.into();

Best Practices

  • Keep attribute macros focused and predictable.
  • Provide clear compiler errors.
  • Preserve user-written code as much as possible.
  • Prefer derives for trait implementations and attributes for behavior modification.