Skip to content
← All Rust articles

Rust explainer

Procedural macros: the three kinds

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

Compare function-like, derive and attribute macros with a small runnable example that shows what each expansion changes.

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

A procedural macro is Rust code that runs during compilation. It receives tokens and returns tokens for the compiler to process. The three forms differ in what they receive and where their output goes.

Form Example invocation Input Output
Function-like make_value!() Tokens inside the delimiters Replaces the invocation
Derive #[derive(ExtraAnswer)] A struct, enum or union definition Adds items alongside the original definition
Attribute #[replace_answer] Attribute arguments and the annotated item Replaces the annotated item

A derive does not edit the original fields or body. An attribute macro can change an item, preserve it or remove it. Not every attribute is a procedural macro: #[test], for example, is a built-in attribute.

Why there are two crates

Define procedural macros at the root of a library crate configured with proc-macro = true. Another crate imports and invokes them. The compiler builds the macro library for the host before running it to expand the consumer's code, including when the consumer targets another platform.

A proc-macro library cannot invoke its own macros within that same crate. It can contain private helpers, but its public exports are restricted to the supported procedural-macro entry points. Put public runtime types and traits in an ordinary library.

Integration tests in the same Cargo package are separate crates and can invoke the macros. Unit tests can test ordinary private helper functions; they cannot directly use a procedural macro defined in the same crate. Tools such as trybuild can also check examples that should compile or produce errors.

This lesson is an article because the site's exercise runner compiles a single binary. The following example uses two sibling directories and can be run locally.

A runnable example

Create lesson_macros/Cargo.toml:

[package]
name = "lesson_macros"
version = "0.1.0"
edition = "2024"

[lib]
proc-macro = true

Put these three definitions in lesson_macros/src/lib.rs:

use proc_macro::TokenStream;

#[proc_macro]
pub fn make_value(_input: TokenStream) -> TokenStream {
    "7_u32".parse().expect("valid fixed expression")
}

#[proc_macro_derive(ExtraAnswer, attributes(example_note))]
pub fn extra_answer(_input: TokenStream) -> TokenStream {
    "fn derived_answer() -> u32 { 11 }"
        .parse()
        .expect("valid fixed item")
}

#[proc_macro_attribute]
pub fn replace_answer(
    _args: TokenStream,
    _item: TokenStream,
) -> TokenStream {
    "fn replacement_answer() -> u32 { 13 }"
        .parse()
        .expect("valid fixed item")
}

These small macros intentionally ignore their inputs. Their fixed names make the expansion easy to inspect; they are demonstrations, not reusable generators. Applying ExtraAnswer twice in one module would define derived_answer twice and cause an error.

Create demo/Cargo.toml beside the lesson_macros directory:

[package]
name = "demo"
version = "0.1.0"
edition = "2024"

[dependencies]
lesson_macros = { path = "../lesson_macros" }

Then put this in demo/src/main.rs:

use lesson_macros::{make_value, replace_answer, ExtraAnswer};

#[derive(ExtraAnswer)]
struct Original {
    #[example_note]
    value: u32,
}

#[replace_answer]
fn removed_answer() -> u32 {
    99
}

fn main() {
    assert_eq!(make_value!(), 7);

    let original = Original { value: 5 };
    assert_eq!(original.value, 5);
    assert_eq!(derived_answer(), 11);

    assert_eq!(replacement_answer(), 13);
}

From the directory containing both crates, run:

cargo run --manifest-path demo/Cargo.toml

The program exits successfully when all assertions pass. The results show the three expansion behaviors:

  • make_value!() becomes an expression with value 7_u32.
  • ExtraAnswer keeps Original and adds derived_answer.
  • replace_answer removes removed_answer and emits replacement_answer. Calling the old name would fail to compile.

Inputs and helper attributes

A function-like macro receives the tokens inside its delimiters. These tokens need not form a Rust expression, but must tokenize successfully with balanced delimiters. Declarative macro_rules! macros can also accept custom token syntax. That alone is not a reason to choose a procedural macro.

A derive receives the annotated definition, including its fields and relevant attributes. Declaring attributes(example_note) allows the inert helper attribute used above. It does not implement any behavior: this demonstration ignores the note. A real derive would parse and interpret it. Other macros can also see these helper attributes.

An attribute macro receives two streams. For #[replace_answer(level = "debug")], the first contains level = "debug"; without arguments it is empty. The second contains the annotated item, including its other attributes but excluding the attribute currently being expanded. Our example ignores both streams.

Writing a practical macro

The compiler-provided proc_macro API is sufficient for the example. Larger macros often use:

  • proc-macro2 for token types that can also be used in ordinary helper libraries and tests.
  • syn to parse tokens into syntax structures.
  • quote to build output tokens, using #name to interpolate a value.

A real derive usually reads the input name, fields and generics, then generates implementations with the appropriate generic parameters and bounds. Parsing a type's syntax does not reveal the compiler's resolved types or which traits it implements. Generated code is checked afterward.

Procedural macros run with the build process's access to files and other resources. Their dependency and build-time behavior matter when choosing a library, just as build scripts do.

The derive walkthrough explains generated implementations. Macro hygiene covers name resolution, and choosing between generics and macros helps decide when the extra machinery is useful.

step 17 of 28

Macros, FFI and Type-Driven Design

Up next Read the code behind derives and attribute macros