BYTEMAGMA

Master Rust Programming

Writing Code That Writes Code: Macros in Rust

As your Rust skills grow, you’ll start to notice patterns in your code—repeated boilerplate, duplicated logic, and repetitive structures that beg to be abstracted. While traits, generics, and functions offer powerful ways to avoid repetition, sometimes they’re not enough. That’s where Rust’s macros come in.


Introduction

Rust macros are a metaprogramming feature that allow you to write code that writes code. They offer a way to eliminate repetition, enforce consistency, and build domain-specific mini-languages tailored to your needs. But with great power comes complexity—Rust macros are a different beast compared to functions or traits, and they require a solid understanding of how they operate under the hood.

In this post, we’ll explore the two main kinds of macros in Rust—declarative macros (macro_rules!) and procedural macros. You’ll learn how they differ, when to use each, and how to wield them safely and idiomatically. Whether you’re building an internal DSL, reducing boilerplate in your codebase, or contributing to macro-heavy crates like serde or tokio, this post will give you the foundation you need to write powerful and readable macros in Rust.


Understanding Macros in Rust

Before diving into syntax and implementation, it’s important to understand the purpose and philosophy behind macros in Rust. Unlike functions or traits, macros operate at a different level of abstraction: they let you write code that generates more code at compile time. This metaprogramming capability is key to reducing boilerplate, enforcing consistency, and enabling domain-specific language design in Rust.

Macros can feel like magic—but they’re built on solid foundations and follow strict compile-time rules. In this section, we’ll look at what macros are, why you might want to use them, and what makes them different from other forms of abstraction.


What Are Macros and Why Use Them?

In Rust, macros are tools for metaprogramming—they allow you to write code that produces other code. While functions let you reuse logic, macros let you reuse syntax. This makes them ideal for eliminating boilerplate, handling variable numbers of arguments, and generating repetitive code structures like impl blocks or struct definitions.

Rust provides two main types of macros:

  • Declarative macros: defined using macro_rules!, these match against patterns and expand into code.
  • Procedural macros: defined in separate crates and used via attributes like #[derive], #[proc_macro], or function-like syntax.

Here are some of the reasons why Rust developers reach for macros:

  • Eliminating boilerplate: You can use macros to generate repetitive impl blocks, error handling code, or enum variants.
  • Enabling internal DSLs: Macros can help you create mini-languages, like html! in Yew or tokio::select!.
  • Handling variable input: Macros can accept a flexible number of arguments and transform them into structured code.
  • Code consistency and DRY principles: Macros help centralize and enforce patterns across your codebase.

A DSL (Domain-Specific Language) is a mini-language designed to express solutions clearly within a specific domain, like HTML, SQL, or state transitions.

Rust macros enable DSLs by letting you write custom syntax that expands into valid Rust code at compile time, allowing expressive, readable abstractions without changing the language itself.

Let’s look at a few simple examples to illustrate what macros can do.


Open a shell window (Terminal on Mac/Linux, Command Prompt or PowerShell on Windows). Then navigate to the directory where you store Rust packages for this blog series, and run the following command:

cargo new macros

Next, change into the newly created macros directory and open it in VS Code (or your favorite IDE).

Note: Using VS Code is highly recommended for following along with this blog series. Be sure to install the Rust Analyzer extension — it offers powerful features like code completion, inline type hints, and quick fixes.

Also, make sure you’re opening the macros directory itself in VS Code. If you open a parent folder instead, the Rust Analyzer extension might not work properly — or at all.

As we see examples in this post, you can either replace the contents of main.rs or instead comment out the current code for future reference with a multi-line comment:

/*
    CODE TO COMMENT OUT
*/

We’ll work through a number of examples throughout this post. Open the file src/main.rs and replace its contents entirely with the code for this example.

Note, don’t worry about the syntax yet. Upcoming sections in this post will analyze the syntax for writing declarative and procedural macros.


Example: A Simple Logging Macro

macro_rules! log_info {
    ($msg:expr) => {
        println!("[INFO]: {}", $msg);
    };
}

fn main() {
    log_info!("Server started");
    log_info!("Connection established");
}

/*
Output:
[INFO]: Server started
[INFO]: Connection established
*/

This is a declarative macro that expands to a println! statement with a prefixed tag, [INFO]. Using this macro keeps log formatting consistent and makes your logging easier to write and maintain. You can even imagine having this macro accept a parameter to determine the prefix: [INFO], [WARN], [ERROR], [SUCCESS], etc.


Example: A Macro That Accepts Multiple Items

macro_rules! say_hello_to {
    ($($name:expr),*) => {
        $(
            println!("Hello, {}!", $name);
        )*
    };
}

fn main() {
    say_hello_to!("Alice", "Bob", "Charlie");
}

/*
Output:
Hello, Alice!
Hello, Bob!
Hello, Charlie!
*/

This macro uses repetition syntax ($()*) to iterate over each input and generate code for each one. It’s handy for reducing boilerplate when performing repeated actions. In this example it iterates over the input names to generate a println!() for each name.


Example: A Macro That Generates Functions

macro_rules! generate_adder {
    ($func_name:ident, $value:expr) => {
        fn $func_name(x: i32) -> i32 {
            x + $value
        }
    };
}

generate_adder!(add_five, 5);
generate_adder!(add_ten, 10);

fn main() {
    println!("{}", add_five(3));
    println!("{}", add_ten(7));
}

/*
Output:
8
17
*/

This macro writes function definitions for you. It’s a great example of writing code that writes code—without macros, you’d need to manually copy and tweak each function.


Macros and Mutability, Ownership, Borrowing, Moving, and Lifetimes

Macros themselves do not directly deal with mutability, ownership, borrowing, moving, or lifetimes.

Macros are expanded before the Rust compiler performs type checking and borrow checking. They operate purely on syntax, generating code that is then passed to the compiler like any other source code.

However…

The code generated by macros must follow Rust’s ownership and borrowing rules.

So while the macro doesn’t enforce or understand ownership/mutability rules itself, the generated code must compile under those rules. That means:

  • You can generate code that creates mut bindings, borrows data, or moves values.
  • If the macro produces invalid ownership or borrowing behavior, the compiler will reject the expanded code.
  • Lifetimes must be valid in the output, even if the macro doesn’t explicitly mention them.

Example: Macro generating a mutable borrow

macro_rules! inc {
    ($var:ident) => {
        $var += 1;
    };
}

fn main() {
    let mut x = 5;
    inc!(x); // Ok: x is mutable
    println!("{}", x);
}

/*
Output:
6
*/

If x were not mutable, the macro call would fail with a compile-time error—even though the macro has no idea what x is.


Example: Macro that causes a move error

macro_rules! consume {
    ($val:expr) => {
        let _owned = $val;
    };
}

fn main() {
    let s = String::from("hello");
    consume!(s); // Moves `s` into `_owned`
    println!("{}", s); // Error: use of moved value
}

This macro causes a move by assigning $val to a new variable, _owned. Since String is a non-Copy type, this transfers ownership of s into _owned, leaving s invalid for further use. The compiler correctly reports a “use of moved value” error when s is used again.

This highlights how macros must still generate code that respects Rust’s ownership rules. Even though the macro itself doesn’t know about ownership, the compiler will enforce it after expansion.


Summary

  • Macros generate code; they don’t “understand” ownership or mutability.
  • Rust’s compiler enforces the rules after macro expansion.
  • You must write macros that expand to valid Rust code—so you must consider ownership, borrowing, and lifetimes in your macro design.

Macros vs Functions: What’s the Difference?

At first glance, macros and functions may seem similar—they both help you avoid repetition and abstract away complexity. But under the hood, they work very differently, and each has its own strengths and limitations.

Functions are part of Rust’s runtime semantics. They take typed input, execute code at runtime, and return typed output. They are checked by the compiler for type correctness and are a core part of any Rust program.

Macros, on the other hand, are a compile-time feature. They operate on the syntax level, expanding into valid Rust code before the compiler even begins type-checking. This allows them to accept a wide variety of input forms, generate repetitive code structures, or even bypass some of the type system’s restrictions (with great care).

Key Differences:

FeatureFunctionMacro
Time of executionRuntimeCompile-time
InputFixed number and typeFlexible patterns, can vary in form
ReturnFixed typeExpands into arbitrary code
Safety & clarityStrong type checksHarder to debug and reason about
Use caseLogic abstractionSyntax abstraction / code generation

Let’s look at examples that illustrate these differences.


Example: A Function for Logging

fn log_info(msg: &str) {
    println!("[INFO]: {}", msg);
}

fn main() {
    log_info("Server started");
    log_info("Connection established");
}

/*
Output:
[INFO]: Server started
[INFO]: Connection established
*/

This is clean and type-safe. The function ensures you’re passing a string slice and benefits from all the usual compiler checks. However, it’s limited to fixed input types and can’t modify the syntax of the call itself.


Example: A Macro That Accepts Flexible Input

macro_rules! log_any {
    ($($arg:tt)*) => {
        println!("[LOG]: {}", format!($($arg)*));
    };
}

fn main() {
    log_any!("Status code: {}", 200);
    log_any!("User {} logged in", "Alice");
}

/*
Output:
[LOG]: Status code: 200
[LOG]: User Alice logged in
*/

Here, the macro allows flexible formatting just like println!, which wouldn’t be possible with a normal function—because functions can’t accept an arbitrary number of arguments or handle input with varying structure like macros can. This kind of flexibility is known as being variadic.


Example: Code That Functions Can’t Generate

macro_rules! define_struct {
    ($name:ident) => {
        struct $name {
            id: u32,
        }
    };
}

define_struct!(User);
define_struct!(Order);

fn main() {
    let user = User { id: 1 };
    let order = Order { id: 42 };
    println!("User ID: {}", user.id);
    println!("Order ID: {}", order.id);
}

/*
Output:
User ID: 1
Order ID: 42
*/

This is where macros truly shine—functions cannot generate new type definitions or new identifiers. Macros can, because they operate before compilation begins. This makes them uniquely suited for boilerplate-heavy scenarios, like implementing traits across many types or creating mini DSLs (Domain Specific Languages).


So while functions are great for abstraction in most situations, macros offer a level of flexibility and metaprogramming that can’t be matched by functions alone. Used together wisely, they let you write powerful, maintainable, and DRY (Don’t Repeat Yourself) code.


Declarative Macros with macro_rules!

Declarative macros—also known as macro_rules! macros—are the original macro system in Rust. They’re built around a powerful pattern-matching engine that lets you match and transform input at the level of tokens, not types. While they look simple at first, they support advanced features like repetition, nested matching, optional tokens, and pattern-based expansion.

In this section, we’ll focus on how macro_rules! works under the hood: its syntax, how patterns are matched, how you bind values from input, and how macro expansion takes place. Once you understand these foundations, you’ll be able to build robust and flexible macros that save time and reduce boilerplate.


Basic Syntax and Matching Rules

A declarative macro is created using the macro_rules! keyword followed by one or more pattern/expansion rules. Each rule matches some form of input and expands it into valid Rust code.

The syntax looks like this:

macro_rules! macro_name {
    ( pattern ) => { expansion };
}

Key parts of the syntax:

  • $var:matcher — binds input to a variable using a matcher, such as expr (expression), ident (identifier), tt (token tree), etc.
  • $(...)* — allows for repetition, matching zero or more times.
  • $(...)+ — same as above, but matches one or more times.
  • Optional separators (commas, semicolons) can be included between repetitions.

Common Matchers in macro_rules!

  • expr – matches a valid Rust expression (e.g., 3 + 5, "hello", some_func())
  • ident – matches an identifier (e.g., variable names like x, my_var)
  • tt (token tree) – matches any single token or balanced group of tokens, without parsing them semantically
  • ty – matches a type (e.g., i32, String, Vec<T>)
  • pat – matches a pattern (like in match, if let, or fn args)
  • block – matches a block of code wrapped in {}

📝 tt is the most flexible but also the loosest—it doesn’t enforce meaning. Use expr or more specific matchers when you want the macro to only accept valid expressions or identifiers.

Let’s look at some simple examples to understand the basics.


Example: Matching an Identifier and an Expression

macro_rules! assign_and_print {
    ($var:ident, $val:expr) => {
        let $var = $val;
        println!("{} = {:?}", stringify!($var), $var);
    };
}

fn main() {
    assign_and_print!(score, 42);
    assign_and_print!(name, "Rust");
}

/*
Output:
score = 42
name = "Rust"
*/

Here:

  • $var:ident matches a variable name (an identifier).
  • $val:expr matches any valid Rust expression.
  • stringify!($var) turns the identifier into a string literal, allowing us to print its name.

Example: Repetition with a Comma Separator

macro_rules! sum {
    ( $( $x:expr ),* ) => {
        {
            let mut total = 0;
            $(
                total += $x;
            )*
            total
        }
    };
}

fn main() {
    let result = sum!(1, 2, 3, 4, 5);
    println!("Sum: {}", result);
}

/*
Output:
Sum: 15
*/

This uses:

  • $( $x:expr ),* — matches zero or more expressions separated by commas.
  • $()* — repeats the inner block for each matched expression.

Here we pass several numbers to the macro separated by commas. The macro starts with a running total of 0, and adds each of the expressions to the running total.


The following shows how we can pass any valid expression, not just numbers.

macro_rules! sum {
    ( $( $x:expr ),* ) => {
        {
            let mut total = 0;
            $(
                total += $x;
            )*
            total
        }
    };
}

fn get_quantity() -> i32 {
    3
}

fn get_price() -> i32 {
    10
}

fn main() {
    let x = 2;
    let y = 4;

    let result = sum!(x + y, get_quantity() * get_price(), 5 * 2);
    println!("Total: {}", result);
}

/*
Output:
Total: 2 + 4 + (3 * 10) + (5 * 2) = 6 + 30 + 10 = 46
*/

The expressions we’re passing are:

x + y (evaluates to addition of two variables)

get_quantity() (evaluates to return value of a function)

get_price() (evaluates to return value of a function)

5 * 2 (evaluates to product of 5 and 2)


Example: Optional Pattern Matching

macro_rules! hello {
    () => {
        println!("Hello, world!");
    };
    ($name:expr) => {
        println!("Hello, {}!", $name);
    };
}

fn main() {
    hello!();
    hello!("Greg");
}

/*
Output:
Hello, world!
Hello, Greg!
*/

This macro defines two patterns:

  • One that matches no arguments.
  • One that matches a single expression.

This pattern-based overloading gives macros flexibility that functions don’t have.


These examples show the foundation of how macro_rules! works. In upcoming subsections, we’ll explore how to write macros that are both flexible and maintainable, and how to avoid common pitfalls that arise when macros become too complex.


Creating Flexible, Readable Macros

Writing macros that are powerful is one thing—writing macros that are maintainable is another. As your macros grow in complexity, readability becomes essential. Good macros should be easy to understand, safe to use, and flexible enough to handle evolving use cases without becoming a tangled mess of patterns.

In this section, we’ll walk through techniques for making your macros more readable and adaptable:

  • Use multiple match arms to handle different cases cleanly
  • Apply repetition thoughtfully, with separators
  • Leverage stringify!, concat!, and other helper macros
  • Write macro invocations that resemble natural code in your domain

Design the macro so that calling it feels intuitive and self-explanatory—like how you’d naturally write or think about the operation in the problem space. For example, create_user!(name: "Alice", age: 30) is more readable and meaningful than create_user!("Alice", 30) in a domain where named arguments make sense.

Let’s look at a few examples that demonstrate these principles.


Example: Multi-Arm Macro for Better Readability

macro_rules! describe_number {
    () => {
        println!("No number provided.");
    };
    ($x:expr) => {
        println!("One number: {}", $x);
    };
    ($x:expr, $y:expr) => {
        println!("Two numbers: {} and {}", $x, $y);
    };
}

fn main() {
    describe_number!();
    describe_number!(10);
    describe_number!(3, 7);
}

/*
Output:
No number provided.
One number: 10
Two numbers: 3 and 7
*/

Here we define a macro with three distinct match arms, each handling a specific use case:

  • no arguments provided
  • one argument provided
  • two arguments provided

This improves both flexibility and readability—each case is simple, predictable, and clearly scoped.


Example: Optional Input With Default Behavior

macro_rules! make_label {
    ($text:expr) => {
        println!("[LABEL]: {}", $text);
    };
    () => {
        println!("[LABEL]: Default");
    };
}

fn main() {
    make_label!("Important!");
    make_label!();
}

/*
Output:
[LABEL]: Important!
[LABEL]: Default
*/

By providing an empty-argument case, this macro supports optional input. This is helpful in real-world scenarios where you might want a default behavior if no input is provided—without requiring users to pass empty strings or dummy values.


Example: Named Arguments Using Patterns

macro_rules! create_user {
    (name: $name:expr, age: $age:expr) => {
        {
            println!("Created user: {} ({} years old)", $name, $age);
        }
    };
}

fn main() {
    create_user!(name: "Alice", age: 30);
    create_user!(name: "Bob", age: 45);
}

/*
Output:
Created user: Alice (30 years old)
Created user: Bob (45 years old)
*/

This example improves clarity by mimicking named arguments, which Rust functions don’t support. This makes macro calls more self-documenting, especially when passing multiple parameters of the same type.


Final Thoughts

By structuring your macros carefully—using multiple match arms, allowing optional input, or emulating named parameters—you can make them much more approachable and error-resistant. Well-designed macros not only save time but also make your codebase more consistent and expressive.


Common Pitfalls and Gotchas

Declarative macros are powerful, but they can also be tricky. Because they operate at the syntactic level before type checking and borrow checking occur, they can lead to confusing compiler errors, unexpected behavior, or difficult debugging experiences if not written carefully.

In this section, we’ll look at some common pitfalls and mistakes you’re likely to encounter when writing macro_rules! macros—and how to avoid them.


Pitfall: Variables Colliding With Local Scope

Macros expand in-place, and if they define variables using user-supplied names, those names can collide with existing identifiers in the caller’s scope. This happens when you allow the caller to inject identifiers into the macro—like variable names passed in as $var:ident.

macro_rules! shadow_bug {
    ($var:ident) => {
        let $var = 42;
        println!("Result inside macro: {}", $var);
    };
}

fn main() {
    let result = "not a number";
    shadow_bug!(result); // Name collision!
    println!("Result outside macro: {}", result);
}

Best Practice:

If your macro needs to declare its own internal variables, choose unique names (e.g., _tmp, _internal_counter) or use scoping blocks to reduce the risk of collisions. Also, avoid injecting user-supplied identifiers as local variable names unless you have a very specific reason to.


What are “scoping blocks” in this context?

In Rust, you can wrap parts of your macro’s expanded code in a block { ... } to confine variable definitions to a smaller scope. This helps avoid name collisions with variables in the calling context.

When you wrap macro-generated variables in their own block, those variables exist only inside that block. Even if the same variable name exists outside the macro, the two don’t conflict.


Without scoping block — possible collision:

macro_rules! risky_macro {
    () => {
        let temp = 42;
        println!("Inside macro: {}", temp);
    };
}

fn main() {
    let temp = "already here";
    risky_macro!(); // Might cause issues in more complex macros
    println!("Outside macro: {}", temp);
}
/*
Output:
Inside macro: 42
Outside macro: already here
*/

This compiles fine here, but if the macro were more complex (e.g. returned temp or used it outside the macro), it might lead to confusion or shadowing.


With a scoping block — safe and clear:

macro_rules! safe_macro {
    () => {{
        let temp = 42;
        println!("Inside macro: {}", temp);
    }};
}

fn main() {
    let temp = "already here";
    safe_macro!(); // ✔️ Safe: temp inside macro is scoped to the block
    println!("Outside macro: {}", temp);
}
/*
Output:
Inside macro: 42
Outside macro: already here
*/

By wrapping the macro body in {{ ... }}, we create a temporary scope. Now temp inside the macro is isolated from temp in main.


Wrapping your macro’s logic in { ... } or {{ ... }} ensures that any variables declared inside won’t accidentally clash with variables in the caller’s code. It’s a good defensive habit for writing clean, collision-free macros.


Pitfall: Unexpected Token Matching

Sometimes a macro may match input in a way you didn’t expect, especially when using loose matchers like tt (token tree).

macro_rules! print_expr {
    ($e:tt) => {
        println!("{:?}", $e);
    };
}

fn main() {
    print_expr!(1 + 2);
}

/*
Output:
error: `+` cannot be formatted with `:?`
*/

Here, $e:tt matches the full token tree 1 + 2, but println!("{:?}", ...) expects a value that implements Debug. The operator + isn’t applied because the expression isn’t parsed.

Fix:

Use $e:expr instead to ensure the macro receives and expands a valid expression:

macro_rules! print_expr {
    ($e:expr) => {
        println!("{:?}", $e);
    };
}
/* 
Output:
3
*/

Pitfall: Missing Commas in Repetition

When defining macros that accept repeated arguments, it’s easy to forget separators like commas. This doesn’t produce a friendly “missing commas” message—instead, the compiler throws a cryptic error because it can’t match the macro input.

macro_rules! list {
    ( $( $item:expr )* ) => {
        vec![$($item)*]
    };
}

fn main() {
    let v = list!(1 2 3); // Missing commas!
}
/* Output: 
error: no rules expected expression `2`
  --> src/main.rs:3:16
      vec![$($item)*]
             ^^^^^ no rules expected this token in macro call
*/

The macro expects a sequence of expressions that match the pattern $( $item:expr )*, but without commas, the input becomes a sequence of bare tokens (1 2 3) that the macro can’t interpret correctly. Rust doesn’t know you’re trying to pass multiple expressions—it just sees a stream of tokens that don’t fit any rule.


Fix: Include the Separator in the Pattern

To accept a comma-separated list, define the macro like this:

macro_rules! list {
    ( $( $item:expr ),* ) => {
        vec![$($item),*]
    };
}

Then call it using commas:

fn main() {
    let v = list!(1, 2, 3);
    println!("{:?}", v);
}

/*
Output:
[1, 2, 3]
*/

This pattern now matches correctly because commas are expected and included in the match rule.


Final Tip

Always test your macros with different inputs, and remember: the compiler sees the expanded code, not the macro input. If something goes wrong, try running cargo expand to see the macro output—it’s one of the best tools for debugging complex macro behavior.


Procedural Macros

While declarative macros (macro_rules!) are pattern-based and live entirely within a single crate, procedural macros give you even more power—they operate on the actual syntax tree of your code. Instead of matching token patterns, procedural macros receive full token streams, manipulate them as data, and generate new code programmatically.

Procedural macros are most commonly seen in popular crates like serde, tokio, and rocket, where they’re used to implement powerful DSLs and reduce repetitive boilerplate. Examples include #[derive(Debug)], #[tokio::main], and html! { ... }.

In this section, we’ll explore what procedural macros are, how they differ from declarative macros, and why they’re useful. Then we’ll dive into the three types of procedural macros and build examples of each.


What Are Procedural Macros?

Procedural macros are Rust functions that take a token stream as input, process it, and return another token stream. Like declarative macros (macro_rules!), they operate on raw syntax rather than typed function arguments.

A token stream is just a sequence of tokens—identifiers, literals, punctuation, etc.—representing the code written in the macro call. Instead of using pattern matching like declarative macros, procedural macros programmatically parse and manipulate these tokens using libraries like syn and quote.

Procedural macros and declarative macros both receive untyped token input. The difference is that:

Procedural macros receive the raw token stream and let you manually parse and transform it using code, offering much more flexibility and power.

Declarative macros (macro_rules!) match token patterns using macro matchers like expr, ident, tt, etc., and transform them using predefined rules.


There are three kinds of procedural macros:

  1. Derive macros: Used to automatically implement traits with #[derive(SomeTrait)]
  2. Attribute macros: Applied to items like functions, structs, or modules: #[my_macro]
  3. Function-like macros: Look like function calls, e.g., sqlx::query!("SELECT * FROM users")

To use procedural macros, you must define them in a separate crate with the proc-macro = true flag.


Example 1: Basic Custom Derive Macro

In this example, we’ll create a custom derive macro called Hello that implements a say_hello() method for any struct it’s applied to.

CD out of the directory containing the Rust package we’ve been working with, and navigate to the directory where you store Rust packages for this blog series.

Procedural macros must live in their own crate marked with proc-macro = true, so we’ll set up a small Cargo workspace with two crates:

  • hello_derive: A library crate that defines the procedural macro
  • hello_example: A binary crate that uses the macro and runs the code

Step-by-Step Setup

1. Create a new workspace folder:

mkdir hello_workspace
cd hello_workspace

Note, your operating system may have different commands to create a new directory.

2. Create the procedural macro crate:

cargo new hello_derive --lib

3. Create the binary crate to test the procedural macro we will create:

cargo new hello_example

4. In directory hello_workspace create a workspace Cargo.toml file, and add this content:

[workspace]
members = [
    "hello_derive",
    "hello_example"
]

5. Edit hello_derive/Cargo.toml to enable procedural macros and add dependencies:

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

[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"
proc-macro2 = "1.0"

6. Replace hello_derive/src/lib.rs with:

use proc_macro::TokenStream;
use quote::quote;
use syn;

#[proc_macro_derive(Hello)]
pub fn derive_hello(input: TokenStream) -> TokenStream {
    let ast = syn::parse(input).unwrap();
    impl_hello(&ast)
}

fn impl_hello(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;

    let expanded = quote! {
        impl Hello for #name {
            fn say_hello(&self) {
                println!("Hello from {}!", stringify!(#name));
            }
        }
    };

    expanded.into()
}

7. Edit hello_example/Cargo.toml to include the macro crate:

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

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

8. Replace hello_example/src/main.rs with:

use hello_derive::Hello;

#[derive(Hello)]
struct MyStruct;

trait Hello {
    fn say_hello(&self);
}

fn main() {
    let s = MyStruct;
    s.say_hello();
}

/*
Output:
Hello from MyStruct!
*/

Run the Example

From the root of your workspace (directory hello_workspace), run:

cargo run -p hello_example

You should see:

Hello from MyStruct!

Understanding the Code: What’s Happening Here?

Let’s walk through what’s going on in the lib.rs and main.rs files step by step.


hello_derive/src/lib.rs – Defining the Macro

#[proc_macro_derive(Hello)]
pub fn derive_hello(input: TokenStream) -> TokenStream {
    let ast = syn::parse(input).unwrap();
    impl_hello(&ast)
}
  • This is the entry point for the #[derive(Hello)] macro.
  • Rust passes in the item the macro is applied to (e.g. a struct) as a TokenStream.
  • We parse that stream into a structured syntax tree (syn::DeriveInput) using the syn crate.
fn impl_hello(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;

    let expanded = quote! {
        impl Hello for #name {
            fn say_hello(&self) {
                println!("Hello from {}!", stringify!(#name));
            }
        }
    };

    expanded.into()
}
  • This is where we generate code using the quote! macro.
  • #name inserts the struct’s name into the generated code.
  • The macro expands into an impl Hello for MyStruct block that defines a simple say_hello() method.

hello_example/src/main.rs – Using the Macro

#[derive(Hello)]
struct MyStruct;
  • This applies our custom macro to MyStruct. Behind the scenes, Rust inserts the generated impl Hello for MyStruct code here.
trait Hello {
    fn say_hello(&self);
}
  • This defines the Hello trait that our macro implements.
  • Normally, this trait would come from a shared crate, but we define it here for the sake of the example.
fn main() {
    let s = MyStruct;
    s.say_hello();
}
  • We create an instance of MyStruct and call the generated method — confirming that our macro works.

Why This Macro Pattern Is Useful

At first glance, this say_hello() example may seem trivial — but the pattern is incredibly powerful. In real-world projects, derive macros like this are used to:

  • Implement traits across dozens or hundreds of types without duplicating code
  • Auto-generate boilerplate like Serialize, Deserialize, Clone, or custom From conversions
  • Integrate with frameworks like serde, diesel, rocket, or actix-web using clean, declarative syntax

In Practice:

Once your macro crate is published (e.g. to crates.io), you can:

  • Reuse it across multiple projects
  • Apply the macro in any crate by adding #[derive(Hello)] (just like you do with #[derive(Debug)])
  • Hide complex or repetitive logic behind simple annotations

Procedural Macros in the Rust Ecosystem

This example mirrors how many foundational libraries in Rust work:

LibraryMacro Usage
serde#[derive(Serialize, Deserialize)]
tokio#[tokio::main]
actix-web#[get("/users")]
clap#[derive(Parser)]

Rust macros let you write clean, declarative code while generating fast, safe, and type-checked logic behind the scenes.


Exercise 2: Responding to Struct Fields in a Derive Macro

This exercise builds directly on Exercise 1. We’ll reuse the same project and workspace structure, but modify the macro implementation to do something more interesting: print how many fields the struct has.


Reuse the Setup from Exercise 1

If you completed Exercise 1, you already have:

  • A workspace folder (e.g., hello_workspace)
  • A macro crate (hello_derive)
  • A binary crate (hello_example)
  • The macro already wired up and working

We’ll now enhance the macro code to count the fields in the struct it’s applied to.


Step-by-Step: Modify Your Project for Exercise 2

1. Update hello_derive/src/lib.rs

Replace the existing impl_hello function with the following code:

fn impl_hello(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;

    let field_count = match &ast.data {
        syn::Data::Struct(data) => data.fields.len(),
        _ => 0,
    };

    let expanded = quote! {
        impl Hello for #name {
            fn say_hello(&self) {
                println!("Hello from {}! I have {} fields.", stringify!(#name), #field_count);
            }
        }
    };

    expanded.into()
}

This version inspects the input struct and counts its fields using syn. That count is then included in the generated method.


2. Update hello_example/src/main.rs

Replace the MyStruct from Exercise 1 with a struct that has multiple fields:

use hello_derive::Hello;

#[derive(Hello)]
struct User {
    id: u32,
    name: String,
}

trait Hello {
    fn say_hello(&self);
}

fn main() {
    let u = User { id: 1, name: "Greg".into() };
    u.say_hello();
}

/*
Output:
Hello from User! I have 2 fields.
*/

Run the Example

From the root of your workspace:

cargo run -p hello_example

Expected output:

Hello from User! I have 2 fields.

What You Learned

This is your first taste of introspecting Rust types using procedural macros. You’re no longer just inserting code—you’re reading and responding to the structure of Rust types at compile time.

This same technique is used by real-world libraries like:

  • serde (for deciding how to serialize each field)
  • clap (for generating CLI arguments from struct fields)
  • thiserror (for auto-generating Display and Error impls based on field names and values)

Procedural macros are more powerful than declarative macros—but they’re also more complex. You must write them in a separate crate, work directly with the token stream, and manage dependencies like syn and quote. But for large-scale code generation, DSLs, and advanced trait derivations, they’re a perfect fit.


Exercise 3: Printing Field Names from a Struct

In this exercise, we’ll take our procedural macro one step further. Instead of just counting the fields, we’ll extract each field’s name and print them at runtime. This introduces a powerful technique: generating code for each field individually.

This is a foundational building block for more advanced derive macros like serde, clap, or thiserror.


Builds on Exercises 1 and 2

If you’ve completed Exercises 1 and 2, you already have:

  • A workspace folder (e.g., hello_workspace)
  • A hello_derive macro crate with #[proc_macro_derive(Hello)]
  • A hello_example binary crate that uses and runs the macro

Step-by-Step: Modify Your Project for Exercise 3

1. Update hello_derive/src/lib.rs

Replace the impl_hello function with this new version:

fn impl_hello(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;

    // Extract field names
    let field_names = match &ast.data {
        syn::Data::Struct(data) => data
            .fields
            .iter()
            .filter_map(|f| f.ident.as_ref()) // skip unnamed (tuple) fields
            .map(|ident| ident.to_string())
            .collect::<Vec<_>>(),
        _ => vec![],
    };

    // Convert field names into a comma-separated string
    let field_list = field_names.join(", ");

    let expanded = quote! {
        impl Hello for #name {
            fn say_hello(&self) {
                println!("Hello from {}! My fields are: {}", stringify!(#name), #field_list);
            }
        }
    };

    expanded.into()
}

2. Update hello_example/src/main.rs

Use a struct with multiple named fields:

use hello_derive::Hello;

#[derive(Hello)]
struct Book {
    title: String,
    author: String,
    pages: u32,
}

trait Hello {
    fn say_hello(&self);
}

fn main() {
    let b = Book {
        title: "Rust in Action".into(),
        author: "Tim McNamara".into(),
        pages: 400,
    };
    b.say_hello();
}

/*
Output:
Hello from Book! My fields are: title, author, pages
*/

Run the Example

From the root of your workspace:

cargo run -p hello_example

Expected output:

Hello from Book! My fields are: title, author, pages

What You Learned

This exercise introduces a core technique used in many real-world macros:

  • Iterating over struct fields
  • Accessing field metadata (names, types, etc.)
  • Dynamically generating per-field output

You now have a basic understanding of syntax tree traversal and dynamic code generation — a huge step in procedural macro mastery.


Attribute Macros

In this section, we’ll explore attribute macros, a powerful type of procedural macro that operates on items like functions, structs, or modules. You’ve likely seen attribute macros before in the wild:

  • #[tokio::main]
  • #[get("/users")] (in actix-web)
  • #[test]

Unlike derive macros (which implement traits), attribute macros give you full control over the item they’re applied to. You can modify the item, wrap it in additional logic, or even replace it entirely.

We’ll start by creating an attribute macro that logs a message before and after a function is called.


Exercise: Writing a #[log_calls] Attribute Macro

This macro will wrap a function and log messages when entering and exiting it.


Project Setup

We’ll create a workspace with two crates:

  • attr_macros: A proc-macro crate that defines the #[log_calls] and #[timed] attribute macros
  • attr_demo: A binary crate that uses and tests the macros

Step-by-Step Setup

1. Create the workspace root

mkdir attr_workspace
cd attr_workspace

2. Create the macro crate

cargo new attr_macros --lib

3. Create the binary crate

cargo new attr_demo

4. Create a workspace manifest Cargo.toml in the root

[workspace]
members = [
    "attr_macros",
    "attr_demo"
]

5. Update attr_macros/Cargo.toml to support procedural macros

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

[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"
proc-macro2 = "1.0"

Implementing the Macro

6. Replace the contents of attr_macros/src/lib.rs with:

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 {
    let input = parse_macro_input!(item as ItemFn);
    let fn_name = &input.sig.ident;
    let fn_block = &input.block;
    let fn_sig = &input.sig;
    let fn_vis = &input.vis;

    let expanded = quote! {
        #fn_vis #fn_sig {
            println!("--> Entering function: {}", stringify!(#fn_name));
            let result = (|| #fn_block)();
            println!("<-- Exiting function: {}", stringify!(#fn_name));
            result
        }
    };

    expanded.into()
}

This macro:

  • Receives the function it’s attached to as a syntax tree (ItemFn)
  • Extracts its signature and body
  • Wraps the body in logging logic

Using the Macro

7. Update attr_demo/Cargo.toml to link the macro crate:

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

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

8. Replace attr_demo/src/main.rs with:

use attr_macros::log_calls;

#[log_calls]
fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

fn main() {
    let message = greet("Greg");
    println!("{}", message);
}

/*
Output:
--> Entering function: greet
<-- Exiting function: greet
Hello, Greg!
*/

Run the Example

From the root of your workspace:

cargo run -p attr_demo

Expected output:

--> Entering function: greet
<-- Exiting function: greet
Hello, Greg!

What You’ve Learned

This example demonstrates how attribute macros can:

  • Access and modify the body of a function
  • Wrap behavior around user-defined code
  • Inject custom logic before and after execution

This technique is used extensively in real-world Rust frameworks for:

  • Tracing and logging
  • Timing functions
  • Registering handlers
  • Managing side effects declaratively

Exercise 2: Writing a #[timed] Attribute Macro

Let’s build another attribute macro called #[timed] that measures how long a function takes to execute and logs the duration to the terminal. This kind of macro is useful for lightweight performance instrumentation without modifying the original function logic.

This exercise builds directly on Exercise 1. We’ll continue using the same workspace and crates (attr_workspace, attr_macros, and attr_demo).


Step-by-Step Instructions

1. Add the new macro to attr_macros/src/lib.rs

Place this below the existing log_calls macro:

#[proc_macro_attribute]
pub fn timed(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let input = parse_macro_input!(item as ItemFn);
    let fn_name = &input.sig.ident;
    let fn_block = &input.block;
    let fn_sig = &input.sig;
    let fn_vis = &input.vis;

    let expanded = quote! {
        #fn_vis #fn_sig {
            let start = std::time::Instant::now();
            let result = (|| #fn_block)();
            let duration = start.elapsed();
            println!("Function `{}` took {:?}", stringify!(#fn_name), duration);
            result
        }
    };

    expanded.into()
}

This macro:

  • Wraps the function body in a closure so we can capture the return value
  • Starts a timer before running the function body
  • Logs the duration after execution

2. Replace the content of attr_demo/src/main.rs with the following to use both macros:

use attr_macros::{log_calls, timed};

#[log_calls]
fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

#[timed]
fn compute_sum() -> i64 {
    let mut total = 0;
    for i in 0..1_000_000 {
        total += i;
    }
    total
}

fn main() {
    let message = greet("Greg");
    println!("{}", message);

    let total = compute_sum();
    println!("Sum: {}", total);
}

/*
Output (approximate):
--> Entering function: greet
<-- Exiting function: greet
Hello, Greg!
Function `compute_sum` took 1.23ms
Sum: 499999500000
*/

You can apply these macros to any function that doesn’t require special lifetimes, async behavior, or custom return types.


Note, sometimes due to compiler bugs or other issues you might get mysterious errors. Often running these two commands will help clean things up and fix the errors:

cargo clean
cargo build

Sometimes to fix such errors you might need to execute this command to upgrade your Rust toolchain:

rustup update

Unfortunately sometimes this still doesn’t work, so if you’re still getting errors you may need to move the two attribute macros to their own library crates. Here are the step-by-step instructions:

Step 1: Create the new macro crates

Run these inside the existing attr_workspace directory:

cargo new attr_log --lib
cargo new attr_timed --lib

Step 2: Add the new crates to the workspace manifest

Replace the contents of your root Cargo.toml (attr_workspace/Cargo.toml) to add the new crates, and to remove the attr_macros crate:

[workspace]
members = [
    "attr_log",
    "attr_timed",
    "attr_demo"
]

Step 3: Move #[log_calls] into attr_log

Edit attr_log/Cargo.toml:

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

[lib]
proc-macro = true

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

Replace attr_log/src/lib.rs:

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 {
    let input = parse_macro_input!(item as ItemFn);
    let name = &input.sig.ident;
    let sig = &input.sig;
    let block = &input.block;
    let vis = &input.vis;

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

    expanded.into()
}

Step 4: Move #[timed] into attr_timed

Edit attr_timed/Cargo.toml:

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

[lib]
proc-macro = true

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

Replace attr_timed/src/lib.rs:

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

#[proc_macro_attribute]
pub fn timed(_attr: TokenStream, item: TokenStream) -> TokenStream {
    let input = parse_macro_input!(item as ItemFn);
    let name = &input.sig.ident;
    let sig = &input.sig;
    let block = &input.block;
    let vis = &input.vis;

    let expanded = quote! {
        #vis #sig {
            let start = std::time::Instant::now();
            let result = (|| #block)();
            let duration = start.elapsed();
            println!("Function `{}` took {:?}", stringify!(#name), duration);
            result
        }
    };

    expanded.into()
}

Step 5: Update attr_demo to use both crates

Edit attr_demo/Cargo.toml:

[dependencies]
attr_log = { path = "../attr_log" }
attr_timed = { path = "../attr_timed" }

Replace the contents of attr_demo/src/main.rs with the following:

use attr_log::log_calls;
use attr_timed::timed;

#[log_calls]
fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

#[timed]
fn compute_sum() -> i64 {
    (0..1_000_000).sum()
}

fn main() {
    let message = greet("Greg");
    println!("{}", message);

    let total = compute_sum();
    println!("Sum: {}", total);
}

/*
Output:
--> Entering function: greet
<-- Exiting function: greet
Hello, Greg!
Function `compute_sum` took [some duration]
Sum: 499999500000
*/

Step 6: Remove the attr_macros directory

Because we move the two attribute macros into their own library crates, we no longer need the attr_macros crate and you can delete the directory.


Run the Example

From the root of your workspace:

cargo run -p attr_demo

You should see output from both macros, demonstrating logging and timing behavior injected at compile time.

Expected output:

--> Entering function: greet
<-- Exiting function: greet
Hello, Greg!
Function `compute_sum` took 11.61778ms
Sum: 499999500000

What You’ve Learned

With just a few lines of procedural macro code, you now know how to:

  • Access and parse function items with syn::ItemFn
  • Wrap function bodies with pre/post logic
  • Use attribute macros to enhance functionality without modifying original code

This kind of technique enables:

  • Logging wrappers (#[log_calls], #[trace])
  • Performance profiling (#[timed], #[benchmark])
  • Safety checks, metrics collection, and more

Real-world libraries like tokio, tracing, and criterion all use these kinds of attribute macros to add powerful behavior while keeping your code clean and declarative.


Derive Macros

Derive macros are a kind of procedural macro that let you automatically generate trait implementations using:

#[derive(...)]

You’ve already used them with built-in traits like Debug, Clone, and PartialEq.

In this section, we’ll learn how to:

  • Create a custom derive macro called Hello
  • Use syn to parse struct metadata
  • Use quote! to generate trait implementations

Exercise 1: Basic #[derive(Hello)] Macro

This macro implements a custom Hello trait that defines a say_hello() method for any struct it’s applied to.


Project Setup

We’ll create a workspace with two crates:

  • hello_derive: A proc-macro crate where we define the derive macro
  • hello_example: A binary crate that uses and tests it

Step-by-Step Setup

1. Create the workspace and move into it

mkdir derive_workspace
cd derive_workspace

2. Create the macro crate

cargo new hello_derive --lib

3. Create the binary crate

cargo new hello_example

4. Create a workspace Cargo.toml in the derive_workspace directory

[workspace]
members = [
    "hello_derive",
    "hello_example"
]

5. Update hello_derive/Cargo.toml to support procedural macros

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

[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"
proc-macro2 = "1.0"

Implementing the Macro

6. Replace the contents of hello_derive/src/lib.rs with the following:

use proc_macro::TokenStream;
use quote::quote;
use syn;

#[proc_macro_derive(Hello)]
pub fn derive_hello(input: TokenStream) -> TokenStream {
    let ast = syn::parse(input).unwrap();
    impl_hello(&ast)
}

fn impl_hello(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;

    let expanded = quote! {
        impl Hello for #name {
            fn say_hello(&self) {
                println!("Hello from {}!", stringify!(#name));
            }
        }
    };

    expanded.into()
}

Using the Macro

7. Update hello_example/Cargo.toml to include the macro crate:

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

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

8. Replace hello_example/src/main.rs with:

use hello_derive::Hello;

#[derive(Hello)]
struct MyStruct;

trait Hello {
    fn say_hello(&self);
}

fn main() {
    let s = MyStruct;
    s.say_hello();
}

/*
Output:
Hello from MyStruct!
*/

Run the Example

From the root of the workspace:

cargo run -p hello_example

Expected output:

Hello from MyStruct!

Why This Is Useful

You now have your first working derive macro. While this one just prints a greeting, the pattern is extremely useful for:

  • Auto-generating trait implementations across multiple types
  • Reducing boilerplate
  • Creating user-friendly APIs (like serde, clap, thiserror, etc.)

Exercise 2: Count Struct Fields and Print the Number

In this exercise, we’ll build on Exercise 1 by enhancing the macro to count how many fields a struct has and include that number in the output.

This requires inspecting the struct body using syn and inserting a literal value into the generated method.

We’ll place this new macro in a separate crate to avoid compiler issues.


Step 1: Add a new crate to the workspace

From inside derive_workspace, run:

cargo new hello_fields --lib

Step 2: Update the root Cargo.toml to include it

Edit derive_workspace/Cargo.toml:

[workspace]
members = [
    "hello_example",
    "hello_derive",
    "hello_fields"
]

Step 3: Edit hello_fields/Cargo.toml

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

[lib]
proc-macro = true

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

Step 4: Implement the macro in hello_fields/src/lib.rs

use proc_macro::TokenStream;
use quote::quote;
use syn;

#[proc_macro_derive(Hello)]
pub fn derive_hello(input: TokenStream) -> TokenStream {
    let ast = syn::parse(input).unwrap();
    impl_hello(&ast)
}

fn impl_hello(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;

    let field_count = match &ast.data {
        syn::Data::Struct(data) => data.fields.len(),
        _ => 0,
    };

    let expanded = quote! {
        impl Hello for #name {
            fn say_hello(&self) {
                println!("Hello from {}! I have {} fields.", stringify!(#name), #field_count);
            }
        }
    };

    expanded.into()
}

Step 5: Update hello_example to use the new macro

Edit hello_example/Cargo.toml:

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

Replace hello_example/src/main.rs:

use hello_fields::Hello;

#[derive(Hello)]
struct User {
    id: u32,
    name: String,
}

trait Hello {
    fn say_hello(&self);
}

fn main() {
    let user = User {
        id: 1,
        name: "Greg".into(),
    };
    user.say_hello();
}

/*
Output:
Hello from User! I have 2 fields.
*/

Run the example

cargo run -p hello_example

Expected output:

Hello from User! I have 2 fields.

Exercise 3: Print the Names of Struct Fields

Now let’s take things further by printing the actual names of the fields inside the struct.

This builds on Exercise 2 but introduces per-field iteration using syn.

We’ll define this macro in its own crate as well.


Step 1: Create a new crate for the macro

cargo new hello_fieldnames --lib

Step 2: Update the workspace manifest again

Edit derive_workspace/Cargo.toml:

[workspace]
members = [
    "hello_example",
    "hello_derive",
    "hello_fields",
    "hello_fieldnames"
]

Step 3: Edit hello_fieldnames/Cargo.toml

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

[lib]
proc-macro = true

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

Step 4: Implement the macro in hello_fieldnames/src/lib.rs

use proc_macro::TokenStream;
use quote::quote;
use syn;

#[proc_macro_derive(Hello)]
pub fn derive_hello(input: TokenStream) -> TokenStream {
    let ast = syn::parse(input).unwrap();
    impl_hello(&ast)
}

fn impl_hello(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;

    let field_names = match &ast.data {
        syn::Data::Struct(data) => data
            .fields
            .iter()
            .filter_map(|f| f.ident.as_ref())
            .map(|ident| ident.to_string())
            .collect::<Vec<_>>(),
        _ => vec![],
    };

    let field_list = field_names.join(", ");

    let expanded = quote! {
        impl Hello for #name {
            fn say_hello(&self) {
                println!("Hello from {}! My fields are: {}", stringify!(#name), #field_list);
            }
        }
    };

    expanded.into()
}

Step 5: Update hello_example to use the new macro

Edit hello_example/Cargo.toml again:

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

Replace hello_example/src/main.rs again:

use hello_fieldnames::Hello;

#[derive(Hello)]
struct Book {
    title: String,
    author: String,
    pages: u32,
}

trait Hello {
    fn say_hello(&self);
}

fn main() {
    let book = Book {
        title: "Rust in Action".into(),
        author: "Tim McNamara".into(),
        pages: 400,
    };
    book.say_hello();
}

/*
Output:
Hello from Book! My fields are: title, author, pages
*/

Run the example

cargo run -p hello_example

Expected output:

Hello from Book! My fields are: title, author, pages

✅ What You’ve Learned

You now know how to:

  • Access struct metadata (name and fields)
  • Iterate over and use field names dynamically
  • Safely generate trait impls for multiple struct shapes
  • Organize macros cleanly using one crate per macro to avoid compiler issues

In this section, we progressively built up a custom derive macro — starting with a basic implementation and expanding it to inspect the struct’s contents and generate tailored behavior. Along the way, you learned how to:

  • Create a #[derive(...)] macro using syn and quote
  • Access and manipulate struct metadata like field counts and names
  • Organize your macros safely across crates to avoid common compiler issues

This pattern mirrors real-world crates like serde, clap, and thiserror, which use derive macros to generate powerful, boilerplate-free code from your types.

Next, we’ll explore a different kind of procedural macro: function-like macros, which look like regular Rust function calls but generate entire code blocks dynamically from input tokens.


Function-like Macros

Function-like procedural macros look like regular function calls, e.g., my_macro!(...), but they can take arbitrary token input and generate any valid Rust code at compile time.

You’ve seen them in libraries like:

  • sqlx::query!()
  • html!() in yew
  • regex!() and quote!()

Let’s walk through how to write a function-like macro that takes input and generates Rust code.


Exercise: Defining a define_const! Macro

We’ll create a function-like macro that generates a constant with a given name and value.


Project Setup

We’ll create a workspace with:

  • func_macros: A proc-macro crate defining the macro
  • func_demo: A binary crate that uses it

Step-by-Step Setup

1. Create the workspace and move into it

mkdir func_workspace
cd func_workspace

2. Create the macro crate

cargo new func_macros --lib

3. Create the binary crate

cargo new func_demo

4. Create a workspace Cargo.toml in the root

[workspace]
members = [
    "func_macros",
    "func_demo"
]

5. Update func_macros/Cargo.toml:

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

[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"
proc-macro2 = "1.0"

Implementing the Macro

6. Replace func_macros/src/lib.rs with:

use proc_macro::TokenStream;
use syn::{parse_macro_input, Ident, LitInt, Token};
use quote::quote;
use syn::parse::{Parse, ParseStream, Result};

struct ConstDef {
    name: Ident,
    _comma: Token![,],
    value: LitInt,
}

impl Parse for ConstDef {
    fn parse(input: ParseStream) -> Result<Self> {
        Ok(ConstDef {
            name: input.parse()?,
            _comma: input.parse()?,
            value: input.parse()?,
        })
    }
}

#[proc_macro]
pub fn define_const(input: TokenStream) -> TokenStream {
    let ConstDef { name, value, .. } = parse_macro_input!(input as ConstDef);

    let expanded = quote! {
        const #name: i32 = #value;
    };

    expanded.into()
}

Using the Macro

7. Update func_demo/Cargo.toml:

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

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

8. Replace func_demo/src/main.rs with:

use func_macros::define_const;

define_const!(MAX_COUNT, 100);

fn main() {
    println!("The max count is: {}", MAX_COUNT);
}

/*
Output:
The max count is: 100
*/

Run the Example

From the root of the workspace:

cargo run -p func_demo

Expected output:

The max count is: 100

Exercise 2: Create a Map with make_map!

Let’s build a function-like macro that lets users generate a HashMap from a list of key-value pairs:

let map = make_map!(
    "apples" => 3,
    "bananas" => 5
);

Step-by-Step

1. Create a new macro crate

From inside func_workspace:

cargo new func_make_map --lib

2. Add it to your workspace manifest (Cargo.toml in root):

[workspace]
members = [
    "func_macros",
    "func_make_map",
    "func_demo"
]

3. Edit func_make_map/Cargo.toml:

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

[lib]
proc-macro = true

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

4. Implement the macro in func_make_map/src/lib.rs:

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Token, Expr, punctuated::Punctuated};
use syn::parse::{Parse, ParseStream, Result};

struct MapInput {
    entries: Punctuated<MapEntry, Token![,]>,
}

struct MapEntry {
    key: Expr,
    _arrow: Token![=>],
    value: Expr,
}

impl Parse for MapEntry {
    fn parse(input: ParseStream) -> Result<Self> {
        Ok(MapEntry {
            key: input.parse()?,
            _arrow: input.parse()?,
            value: input.parse()?,
        })
    }
}

impl Parse for MapInput {
    fn parse(input: ParseStream) -> Result<Self> {
        Ok(MapInput {
            entries: Punctuated::parse_terminated(input)?,
        })
    }
}

#[proc_macro]
pub fn make_map(input: TokenStream) -> TokenStream {
    let MapInput { entries } = parse_macro_input!(input as MapInput);

    let keys = entries.iter().map(|e| &e.key);
    let values = entries.iter().map(|e| &e.value);

    let expanded = quote! {{
        let mut map = std::collections::HashMap::new();
        #( map.insert(#keys, #values); )*
        map
    }};

    expanded.into()
}

5. Update func_demo/Cargo.toml:

[dependencies]
func_macros = { path = "../func_macros" }
func_make_map = { path = "../func_make_map" }

6. Update func_demo/src/main.rs:

use func_macros::define_const;
use func_make_map::make_map;
use std::collections::HashMap;

define_const!(MAX_COUNT, 100);

fn main() {
    println!("The max count is: {}", MAX_COUNT);

    let fruits: HashMap<&str, i32> = make_map!(
        "apples" => 3,
        "bananas" => 5,
        "pears" => 2
    );

    for (name, count) in fruits.iter() {
        println!("{}: {}", name, count);
    }
}

/*
Output:
The max count is: 100
apples: 3
bananas: 5
pears: 2
*/

Run the Example

From the root of the workspace:

cargo run -p func_demo

Expected output:

The max count is: 100
apples: 3
pears: 2
bananas: 5

Exercise 3: Generate Functions Dynamically with generate_fn!

In this final example, we’ll define a macro that creates a full function from a macro call, such as:

generate_fn!(greet_user, {
    println!("Hello from a generated function!");
});

This macro will expand to:

fn greet_user() {
    println!("Hello from a generated function!");
}

Step-by-Step

1. Create the macro crate

cargo new func_generate_fn --lib

2. Update workspace manifest:

[workspace]
members = [
    "func_macros",
    "func_make_map",
    "func_generate_fn",
    "func_demo"
]

3. Edit func_generate_fn/Cargo.toml:

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

[lib]
proc-macro = true

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

4. Implement the macro in func_generate_fn/src/lib.rs:

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Ident, Block, Token};
use syn::parse::{Parse, ParseStream, Result};

struct FnInput {
    name: Ident,
    _comma: Token![,],
    body: Block,
}

impl Parse for FnInput {
    fn parse(input: ParseStream) -> Result<Self> {
        Ok(FnInput {
            name: input.parse()?,
            _comma: input.parse()?,
            body: input.parse()?,
        })
    }
}

#[proc_macro]
pub fn generate_fn(input: TokenStream) -> TokenStream {
    let FnInput { name, body, .. } = parse_macro_input!(input as FnInput);

    let expanded = quote! {
        fn #name() {
            #body
        }
    };

    expanded.into()
}

5. Update func_demo/Cargo.toml:

[dependencies]
func_macros = { path = "../func_macros" }
func_make_map = { path = "../func_make_map" }
func_generate_fn = { path = "../func_generate_fn" }

6. Update func_demo/src/main.rs again:

use func_macros::define_const;
use func_make_map::make_map;
use func_generate_fn::generate_fn;
use std::collections::HashMap;

define_const!(MAX_COUNT, 100);

generate_fn!(greet_user, {
    println!("Hello from a generated function!");
});

fn main() {
    println!("The max count is: {}", MAX_COUNT);

    greet_user();

    let items: HashMap<&str, i32> = make_map!(
        "apples" => 3,
        "bananas" => 5,
        "pears" => 2
    );

    for (item, qty) in items.iter() {
        println!("{}: {}", item, qty);
    }
}

/*
Output:
The max count is: 100
Hello from a generated function!
apples: 3
bananas: 5
pears: 2
*/

Run the Example

From the root of the workspace:

cargo run -p func_demo

Expected output:

The max count is: 100
Hello from a generated function!
bananas: 5
apples: 3
pears: 2

What You’ve Learned

Across these three exercises, you progressively built up real-world function-like macros and learned how to:

  • Accept custom macro input using syn::parse
  • Define custom structs to model and validate macro arguments
  • Use quote! and repetition to generate scalable output
  • Dynamically generate expressions, HashMaps, and entire function definitions
  • Organize macro crates in a clean, modular way to prevent compiler issues

This pattern powers real libraries like sqlx, regex, and html!, giving developers expressive syntax without sacrificing performance or type safety.


Building and Using Procedural Macro Crates

So far, you’ve seen how procedural macros — whether they’re attribute macros, derive macros, or function-like macros — can generate code at compile time. But to use them effectively in real projects, you need to understand how they’re packaged, structured, and integrated into other crates.

In this section, we’ll walk through how to set up a procedural macro crate from scratch, how to import and use it in a binary or library crate, and a few key rules you need to know to avoid common pitfalls.


Setting Up a Procedural Macro Crate

All procedural macros — no matter their type — must be defined in a dedicated crate with a special configuration. You cannot define procedural macros in the same crate that uses them. This separation allows the Rust compiler to properly handle macro expansion during compilation.

Let’s go step-by-step to create a basic procedural macro crate and demonstrate its use from a separate binary crate.


Step-by-Step: Hello World Procedural Macro

This macro will expand into a simple println! statement at the call site.


1. Create a new workspace folder

mkdir macro_workspace
cd macro_workspace

2. Create the macro crate

cargo new hello_macro --lib

3. Create the binary crate that will use it

cargo new hello_user

4. Create a workspace Cargo.toml in the root of macro_workspace

[workspace]
members = [
    "hello_macro",
    "hello_user"
]

5. Edit hello_macro/Cargo.toml

Mark the crate as a procedural macro crate and add dependencies:

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

[lib]
proc-macro = true

[dependencies]
proc-macro2 = "1.0"
quote = "1.0"

6. Write the macro in hello_macro/src/lib.rs

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro]
pub fn hello_world(_input: TokenStream) -> TokenStream {
    let expanded = quote! {
        println!("Hello from the macro!");
    };
    expanded.into()
}

This defines a simple function-like macro hello_world!() that generates a println! at the call site.


7. Use the macro in hello_user

Edit hello_user/Cargo.toml:

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

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

Edit hello_user/src/main.rs:

use hello_macro::hello_world;

fn main() {
    hello_world!();
}

/*
Output:
Hello from the macro!
*/

Run the Example

From the root of macro_workspace:

cargo run -p hello_user

Expected output:

Hello from the macro!

Exporting and Importing Macros Across Crates

Once you’ve defined one or more procedural macros in a dedicated crate, the next step is importing and using them from another crate. This section shows how to:

  • Export multiple macros from a procedural macro crate
  • Import them into another crate
  • Use them safely and idiomatically

We’ll build on the setup from the previous section using hello_macro (proc-macro crate) and hello_user (binary crate).


Step-by-Step: Export and Use Multiple Macros

We’ll expand hello_macro by adding another function-like macro called greet!.


1. Update hello_macro/src/lib.rs to export both macros

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro]
pub fn hello_world(_input: TokenStream) -> TokenStream {
    let expanded = quote! {
        println!("Hello from the macro!");
    };
    expanded.into()
}

#[proc_macro]
pub fn greet(_input: TokenStream) -> TokenStream {
    let expanded = quote! {
        println!("Greetings from the greet! macro!");
    };
    expanded.into()
}

These two macros are both pub and annotated with #[proc_macro], so they’re automatically exported from the crate.


2. Use both macros in hello_user

Update hello_user/src/main.rs:

use hello_macro::{hello_world, greet};

fn main() {
    hello_world!();
    greet!();
}

/*
Output:
Hello from the macro!
Greetings from the greet! macro!
*/

Run the Example

From the root of your workspace:

cargo run -p hello_user

Expected output:

Hello from the macro!
Greetings from the greet! macro!

🧠 What You’ve Learned

  • Procedural macros must be marked pub and use one of the #[proc_macro], #[proc_macro_derive], or #[proc_macro_attribute] attributes
  • Multiple macros can be exported from the same crate
  • Consumers must import them explicitly: use my_crate::my_macro;
  • You call them using macro-style syntax: my_macro!();

This structure allows you to group related macros in a single crate and use them flexibly across many consumers.


Real-World Examples and Use Cases

By now you’ve seen how procedural macros can generate code at compile time — and you’ve built function-like, attribute, and derive macros from scratch. But how do macros show up in real production code?

This section highlights practical, real-world use cases of procedural macros in Rust. We’ll focus on how macros simplify everyday development tasks, eliminate boilerplate, and power many of the ecosystem’s most popular crates.


Reducing Boilerplate with Custom Derive

One of the most common and powerful uses of procedural macros is creating #[derive(...)] macros that auto-implement traits. Instead of manually writing repetitive trait implementations for every struct, you can define the behavior once and apply it everywhere with a single annotation.

Let’s create a #[derive(ToCsv)] macro that converts a struct into a CSV row — a realistic task in data serialization or reporting tools.


Step-by-Step: #[derive(ToCsv)] Macro

We’ll create a macro that generates a to_csv() method for any struct, returning a string with comma-separated field values.


1. Create a new macro crate

In your existing workspace (e.g. macro_workspace):

cargo new tocsv_derive --lib

2. Update macro_workspace/Cargo.toml

[workspace]
members = [
    "hello_macro",
    "hello_user", 
    "tocsv_derive"
]

3. Edit tocsv_derive/Cargo.toml

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

[lib]
proc-macro = true

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

4. Implement #[derive(ToCsv)] in tocsv_derive/src/lib.rs

use proc_macro::TokenStream;
use quote::quote;
use syn;

#[proc_macro_derive(ToCsv)]
pub fn to_csv_derive(input: TokenStream) -> TokenStream {
    let ast = syn::parse(input).unwrap();
    impl_to_csv(&ast)
}

fn impl_to_csv(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;

    let fields = match &ast.data {
        syn::Data::Struct(data) => data
            .fields
            .iter()
            .filter_map(|f| f.ident.as_ref())
            .map(|ident| quote! { self.#ident.to_string() })
            .collect::<Vec<_>>(),
        _ => vec![],
    };

    let joined = quote! {
        vec![#(#fields),*].join(",")
    };

    let expanded = quote! {
        impl #name {
            pub fn to_csv(&self) -> String {
                #joined
            }
        }
    };

    expanded.into()
}

5. Use the macro in hello_user

Update hello_user/Cargo.toml:

[dependencies]hello_macro = { path = "../hello_macro" }
tocsv_derive = { path = "../tocsv_derive" }

Update hello_user/src/main.rs:

use hello_macro::{hello_world, greet};
use tocsv_derive::ToCsv;

#[derive(ToCsv)]
struct Product {
    id: u32,
    name: String,
    price: f32,
}

fn main() {
    hello_world!();
    greet!();

    let item = Product {
        id: 1,
        name: "Book".into(),
        price: 12.99,
    };

    println!("{}", item.to_csv());
}

/*
Output:
Hello from the macro!
Greetings from the greet! macro!
1,Book,12.99
*/

Run the Example

cargo run -p hello_user

Expected output:

Hello from the macro!
Greetings from the greet! macro!
1,Book,12.99

Why This Is Useful

With #[derive(ToCsv)]:

  • You avoid manually writing a to_csv() method for every struct
  • You ensure consistent CSV formatting
  • You centralize behavior in a single macro implementation

This mirrors how serde offers #[derive(Serialize, Deserialize)] or how thiserror implements Display for error types.


Embedding DSLs with Function-like Macros

Function-like procedural macros allow developers to create mini-languages inside Rust, often called DSLs (Domain-Specific Languages). These DSLs provide syntax tailored to a specific problem domain — like generating SQL queries, building HTML structures, or composing finite state machines — while still compiling down to valid, type-safe Rust code.

This technique is widely used in real-world Rust crates like:

  • sqlx::query!() for writing SQL inline
  • html!() in the Yew framework
  • regex!() for compile-time regular expressions

In this subsection, we’ll demonstrate how to embed a simple DSL using function-like macros. Our DSL will resemble a tiny markup system that lets us define labeled sections using a more expressive and domain-friendly syntax.

Step-by-Step: A Simple markup! DSL Macro

This macro allows the user to write something like:

markup! {
    section("Intro", "Welcome to the app.");
    section("Features", "Fast, Safe, and Productive.");
}

…and expands it into Rust code like:

println!("[Intro] Welcome to the app.");
println!("[Features] Fast, Safe, and Productive.");

1. Create the macro crate

In your existing macro_workspace:

cargo new markup_macro --lib

2. Add it to your workspace root Cargo.toml

[workspace]
members = [
    "hello_macro",
    "hello_user", 
    "markup_macro", 
    "tocsv_derive"
]

3. Update markup_macro/Cargo.toml

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

[lib]
proc-macro = true

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

4. Implement the DSL in markup_macro/src/lib.rs

use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Token, ExprLit, LitStr};
use syn::parse::{Parse, ParseStream, Result};
use syn::punctuated::Punctuated;

struct Section {
    name: LitStr,
    content: LitStr,
}

impl Parse for Section {
    fn parse(input: ParseStream) -> Result<Self> {
        input.parse::<syn::Ident>()?; // consume "section"

        let content;
        syn::parenthesized!(content in input); // ✅ parse inner tokens

        let name: LitStr = content.parse()?;
        content.parse::<Token![,]>()?;
        let text: LitStr = content.parse()?;

        input.parse::<Token![;]>()?;
        Ok(Section { name, content: text })
    }
}
struct MarkupInput {
    sections: Vec<Section>,
}

impl Parse for MarkupInput {
    fn parse(input: ParseStream) -> Result<Self> {
        let mut sections = Vec::new();
        while !input.is_empty() {
            sections.push(input.parse()?);
        }
        Ok(MarkupInput { sections })
    }
}

#[proc_macro]
pub fn markup(input: TokenStream) -> TokenStream {
    let MarkupInput { sections } = parse_macro_input!(input as MarkupInput);

    let lines = sections.iter().map(|s| {
        let name = &s.name;
        let content = &s.content;
        quote! {
            println!("[{}] {}", #name, #content);
        }
    });

    let expanded = quote! {
        {
            #(#lines)*
        }
    };

    expanded.into()
}

5. Use the macro in hello_user

Update hello_user/Cargo.toml:

[dependencies]
hello_macro = { path = "../hello_macro" }
tocsv_derive = { path = "../tocsv_derive" }
markup_macro = { path = "../markup_macro" }

Update hello_user/src/main.rs:

use hello_macro::{hello_world, greet};
use tocsv_derive::ToCsv;
use markup_macro::markup;

#[derive(ToCsv)]
struct Product {
    id: u32,
    name: String,
    price: f32,
}

fn main() {
    hello_world!();
    greet!();

    let item = Product {
        id: 1,
        name: "Book".into(),
        price: 12.99,
    };

    println!("{}", item.to_csv());

    markup! {
        section("Intro", "Welcome to the app.");
        section("Features", "Fast, Safe, and Productive.");
        section("Goodbye", "Thanks for visiting!");
    }
}

/*
Output:
Hello from the macro!
Greetings from the greet! macro!
1,Book,12.99
[Intro] Welcome to the app.
[Features] Fast, Safe, and Productive.
[Goodbye] Thanks for visiting!
*/

Run the Example

cargo run -p hello_user

Expected output:

Hello from the macro!
Greetings from the greet! macro!
1,Book,12.99
[Intro] Welcome to the app.
[Features] Fast, Safe, and Productive.
[Goodbye] Thanks for visiting!

✅ What You’ve Learned

This example shows how to:

  • Use a function-like macro to define a small DSL inside Rust
  • Parse and validate custom syntax with syn
  • Generate repetitive or structured output from more readable input

You’ve now built a basic but fully working DSL that’s readable, type-safe, and integrates seamlessly into real Rust code.


In this post, we built a solid foundation in procedural macros by going far beyond syntax and theory. You created and used custom derive macros to eliminate repetitive code, attribute macros to inject behavior like logging and timing, and function-like macros to construct lightweight DSLs that integrate cleanly with Rust’s type system.

Each example built on the last, reinforcing the idea that macros are not just a metaprogramming curiosity — they’re a practical, production-ready tool for eliminating boilerplate and increasing expressiveness. With a strong grasp of when and how to use procedural macros, you’re now equipped to bring clarity, power, and precision to your Rust codebases.


We hope this post on Rust macros proves useful! Thank you for stopping by, and for allowing ByteMagma to be part of your journey toward Rust mastery!

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *