Skip to content

A function, macro_rules!, or a procedural macro

Level: 201 · working knowledge

One line: Reach for a function first, macro_rules! when you need to take syntax or write items, and a procedural macro when the code has to stay where it is declared (#[derive], #[attr]) or a new identifier has to be built. On stable 1.98.0 those last two are the ones macro_rules! cannot do, and a procedural macro costs a crate of its own and a syn build.

One task, three ways

The task: list a struct's field names, and give each field a getter, get_x for x. Here it is done by a function, by macro_rules!, and by a derive:

demo/app/src/main.rs
//! One task three ways: list a struct's field names, and give each field a
//! getter.

/// A function cannot see `Point`'s fields. Every name is typed a second time,
/// and nothing checks the copies against the struct.
mod by_function {
    pub struct Point {
        pub x: i32,
        pub y: i32,
    }

    pub fn field_names() -> &'static [&'static str] {
        &["x", "y"]
    }

    impl Point {
        pub fn get_x(&self) -> &i32 {
            &self.x
        }
        pub fn get_y(&self) -> &i32 {
            &self.y
        }
    }
}

/// `macro_rules!` can walk the fields, but only of a struct written inside the
/// call, and only in the shapes its pattern spells out.
macro_rules! with_field_names {
    ($vis:vis struct $name:ident { $($field_vis:vis $field:ident : $ty:ty),* $(,)? }) => {
        $vis struct $name { $($field_vis $field: $ty),* }

        impl $name {
            pub const FIELD_NAMES: &[&str] = &[$(stringify!($field)),*];
        }
    };
}

mod by_macro_rules {
    with_field_names! {
        pub struct Point {
            pub x: i32,
            pub y: i32,
        }
    }
}

/// A derive reads the struct where it is declared, and makes `get_x` from `x`.
mod by_derive {
    use field_names::FieldNames;

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

fn main() {
    let p = by_function::Point { x: 1, y: 2 };
    let names = by_function::field_names();
    println!("a function     {names:?}  get_x() + get_y() = {}", p.get_x() + p.get_y());

    let p = by_macro_rules::Point { x: 1, y: 2 };
    let names = by_macro_rules::Point::FIELD_NAMES;
    println!("macro_rules!   {names:?}  no getters: x + y = {}", p.x + p.y);

    let p = by_derive::Point { x: 1, y: 2 };
    let names = by_derive::Point::FIELD_NAMES;
    println!("a derive       {names:?}  get_x() + get_y() = {}", p.get_x() + p.get_y());
}

Verified output of cargo run -q -p getters_app — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

a function     ["x", "y"]  get_x() + get_y() = 3
macro_rules!   ["x", "y"]  no getters: x + y = 3
a derive       ["x", "y"]  get_x() + get_y() = 3

All three print the same names, and each got there differently.

  • The function field_names() knows nothing about Point: ["x", "y"] and both getters are typed by hand, and renaming a field leaves the list saying "x". A function takes values; it cannot take a type or an identifier, and it cannot declare an impl.
  • macro_rules! does read the fields: $($field:ident : $ty:ty),* walks them, and stringify!($field) turns each into a string. But the struct has to be written inside the call, and the pattern accepts only the shapes it spells out, which here means no attributes on the fields and no generics. And it wrote no getters.
  • The derive sits on an ordinary struct declaration and builds get_x from x. Its source is the proc-macro crate below.

So "only a procedural macro can iterate over a struct's fields" is not quite right. A declarative macro can, if it is handed the struct. What it cannot do on stable is the two things the next section records.

Where macro_rules! stops

It cannot build an identifier

Making get_x out of get_ and x takes the ${concat(…)} metavariable expression:

demo/concat_ident/src/main.rs
//! `macro_rules!` making `get_x` out of `x`. `${concat(…)}` does it, and is
//! not stable in 1.98.0.

macro_rules! getters {
    (struct $name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
        struct $name { $($field: $ty),* }

        impl $name {
            $(fn ${concat(get_, $field)}(&self) -> &$ty { &self.$field })*
        }
    };
}

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

fn main() {
    let p = Point { x: 1, y: 2 };
    println!("{}", p.get_x() + p.get_y());
}

Verified output of cargo build -q -p concat_ident, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error[E0658]: the `concat` meta-variable expression is unstable
 --> concat_ident/src/main.rs:9:19
  |
9 |             $(fn ${concat(get_, $field)}(&self) -> &$ty { &self.$field })*
  |                   ^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: see issue #124225 <https://github.com/rust-lang/rust/issues/124225> for more information

For more information about this error, try `rustc --explain E0658`.
error: could not compile `concat_ident` (bin "concat_ident") due to 1 previous error

When macro_rules! runs out goes through the rest of what a declarative macro cannot compute.

It cannot be a derive or an attribute

A macro_rules! macro can have derive() and attr() rules, which would let it sit on a struct the way a derive does:

demo/rules_derive/src/main.rs
//! `macro_rules!` used as a derive and as an attribute, so the struct stays
//! where it is declared. `derive()` and `attr()` rules do that, and neither is
//! stable in 1.98.0.

macro_rules! FieldNames {
    derive() (struct $name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
        impl $name {
            const FIELD_NAMES: &[&str] = &[$(stringify!($field)),*];
        }
    };
}

macro_rules! with_field_names {
    attr() (struct $name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
        struct $name { $($field: $ty),* }

        impl $name {
            const FIELD_NAMES: &[&str] = &[$(stringify!($field)),*];
        }
    };
}

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

#[with_field_names]
struct Size {
    width: u32,
    height: u32,
}

fn main() {
    let (p, s) = (Point { x: 1, y: 2 }, Size { width: 3, height: 4 });
    println!("{:?} {}", Point::FIELD_NAMES, p.x + p.y);
    println!("{:?} {}", Size::FIELD_NAMES, s.width * s.height);
}

Verified output of cargo build -q -p rules_derive, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error[E0658]: `macro_rules!` derives are unstable
  --> rules_derive/src/main.rs:5:1
   |
 5 | / macro_rules! FieldNames {
 6 | |     derive() (struct $name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
 7 | |         impl $name {
 8 | |             const FIELD_NAMES: &[&str] = &[$(stringify!($field)),*];
 9 | |         }
10 | |     };
11 | | }
   | |_^
   |
   = note: see issue #143549 <https://github.com/rust-lang/rust/issues/143549> for more information

error[E0658]: `macro_rules!` attributes are unstable
  --> rules_derive/src/main.rs:13:1
   |
13 | / macro_rules! with_field_names {
14 | |     attr() (struct $name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
15 | |         struct $name { $($field: $ty),* }
...  |
20 | |     };
21 | | }
   | |_^
   |
   = note: see issue #143547 <https://github.com/rust-lang/rust/issues/143547> for more information

For more information about this error, try `rustc --explain E0658`.
error: could not compile `rules_derive` (bin "rules_derive") due to 2 previous errors

Both errors are E0658 with a tracking issue, so both features exist and are unstable. This page's answer is about stable Rust 1.98.0; if either stabilises, the line between the two kinds of macro moves with it.

The procedural macro that did it

demo/field_names/src/lib.rs
//! `#[derive(FieldNames)]`: a list of a struct's field names, and a getter for
//! each field. The getter's name, `get_x`, is built from the field's, which is
//! the step `macro_rules!` cannot take on stable Rust.

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

#[proc_macro_derive(FieldNames)]
pub fn derive_field_names(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    let Data::Struct(data) = &input.data else {
        return Error::new_spanned(&input.ident, "FieldNames needs a struct")
            .to_compile_error()
            .into();
    };
    let Fields::Named(fields) = &data.fields else {
        return Error::new_spanned(&input.ident, "FieldNames needs named fields")
            .to_compile_error()
            .into();
    };

    let name = &input.ident;
    let fields: Vec<_> = fields.named.iter().map(|f| (f.ident.as_ref().unwrap(), &f.ty)).collect();
    let names = fields.iter().map(|(field, _)| field.to_string());
    let getters = fields.iter().map(|(field, ty)| {
        let getter = format_ident!("get_{field}");
        quote! { pub fn #getter(&self) -> &#ty { &self.#field } }
    });

    quote! {
        impl #name {
            pub const FIELD_NAMES: &[&str] = &[#(#names),*];
            #(#getters)*
        }
    }
    .into()
}

parse_macro_input! turns the tokens into a DeriveInput, the let … else lines reject anything but a struct with named fields, format_ident! builds get_x, and quote! writes the impl. Parsing with syn and Generating with quote take those steps one at a time. The derive ignores generics to stay short; Generics, lifetimes and where is what a real one adds.

What it costs

A crate of its own

proc-macro = true in its manifest, and a dependency from every crate that uses it. A proc-macro crate is about why. The function and the macro_rules! above live in main.rs.

Four crates in every clean build

Verified output of cargo tree -p getters_app — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

getters_app v0.1.0
└── field_names v0.1.0 (proc-macro)
    ├── proc-macro2 v1.0.107
    │   └── unicode-ident v1.0.25
    ├── quote v1.0.47
    │   └── proc-macro2 v1.0.107 (*)
    └── syn v3.0.6
        ├── proc-macro2 v1.0.107 (*)
        ├── quote v1.0.47 (*)
        └── unicode-ident v1.0.25

The filter in cargo_runs.toml removes the folder Cargo prints after each path dependency, since that is an absolute path on the machine that ran it. (*) marks a crate whose dependencies are already shown higher up. The derive brings in proc-macro2, quote, syn and unicode-ident, and none of them ends up in the program: they are compiled for the compiler to run.

How long they take depends on the machine, so the numbers below are one machine's real runs, not an answer key. Each is a cargo build of the program crate in an empty target directory. "Without the derive" is the same main.rs with the by_derive module and its line in main deleted, in a package with no dependencies.

Real runs — cargo 1.98.0, Intel Core i5-10500, macOS 26.6.2, 2026-09-16, load average about 8
without the derive, three clean builds
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.74s
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.51s
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.50s
with the derive, five clean builds
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 10.17s
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 8.83s
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.72s
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 9.80s
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 8.30s
with the derive, after touch app/src/main.rs
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s

cargo build --timings on the third of those put syn alone at 3.87 s. The cost is paid once per clean target directory: a fresh clone, a CI run without a cache, a cargo clean. Editing the program afterwards does not recompile syn. Compile times covers the rest of a build's seconds.

Debugging through an expansion

A bug in a function is in the code you read. A bug in what a macro generated is in code nobody wrote down, and the compiler points at the macro call instead: the refusal on Expanding thiserror marks #[derive(Error)] with "in this derive macro expansion". cargo expand is how you see the code the error is about, and that page reads one expansion line by line.

The decision

A function macro_rules! A procedural macro
Lives in any module any module its own proc-macro = true crate
Takes values tokens that match a pattern you wrote any tokens
Can declare a struct, an impl or a fn no yes yes
Reads a struct's fields no only of a struct written inside the call yes, from #[derive] on the declaration
Can be #[derive(…)] or an attribute no not on stable 1.98.0 (E0658 above) yes
Builds get_x from x no not on stable 1.98.0 (E0658 above) yes, format_ident!
Adds to a clean build nothing nothing syn, quote, proc-macro2, unicode-ident

Read it from the left and stop at the first column that does the job. The chapter's function-like pages go further into the middle column's limits — Why println! is a macro and A routes! macro check a string's contents at compile time — and the attribute pages into the right one, where a macro reads a function's signature: Parse, tweak, re-emit.

If you are coming from another language

  • C and C++. The same ladder exists: a function, then a #define, then a code generator run by the build. macro_rules! is the #define rung made safer: it matches token patterns rather than substituting text, and a captured $e:expr travels inside an invisible group, so $e * 2 cannot rebind 1 + 1 the way a bare #define argument can (Tokens and token streams prints that group). Names are where C is ahead: ## pastes get_ and x into get_x, and stable Rust's macro_rules! has no equivalent. A procedural macro is the code-generator rung, a program that runs during the build, but it receives parsed tokens and runs inside the compiler instead of writing files for it to read.
  • Python. Python needs only the first rung, because a function can inspect a class at runtime: dataclasses.fields(Point) lists a dataclass's fields, and setattr(cls, "get_x", …) makes the getter. Rust has no runtime reflection, so the same work happens at compile time, in a macro. What changes is when you learn about a mistake: a typo in the generated code is a compile error, not an AttributeError on the first call.
  • Java. The first rung is a method, and reflection (Point.class.getDeclaredFields()) does at runtime what the derive does here. The compile-time route is an annotation processor, which, like a procedural macro, is a separately compiled program the compiler loads, and which also costs a build dependency. Java has nothing in the middle: there is no macro_rules! rung, and a record is the language building accessors in, the job this page's derive does.
  • ABAP. (Not machine-checked — CI cannot run ABAP.) A method first; DEFINE … END-OF-DEFINITION for textual repetition, with no way to read a structure's components; and for that, the runtime type services, where a cl_abap_structdescr lists a structure's components with get_components( ). There is no rung where your own code runs inside the ABAP compiler, so what a derive does before the program runs, RTTS does while it runs.

See also

Po polsku

Wybór idzie od najprostszego narzędzia. Funkcja przyjmuje wartości; nie przyjmie typu ani identyfikatora i nie zadeklaruje impl. Makro deklaratywne (macro_rules!) dopasowuje wzorce tokenów, więc może przejść po polach struktury — ale tylko takiej, którą wpisano wewnątrz wywołania. Na stabilnym Ruscie 1.98.0 nie potrafi dwóch rzeczy: sklejić nowego identyfikatora (get_x z x; ${concat(…)} daje E0658) i działać jako #[derive] albo atrybut (reguły derive() i attr() też dają E0658). Makro proceduralne potrafi oba, ale kosztuje: osobny crate z proc-macro = true oraz zależności syn, quote, proc-macro2 i unicode-ident, które przy czystej kompilacji (clean build) na tej maszynie dodały około dziewięciu sekund — raz, a nie przy każdej zmianie programu.

Szukaj po polsku: makro deklaratywne czy proceduralne · macro_rules! a proc_macro · rust macro_rules vs proc macro · rust concat idents macro_rules · rust syn compile time