Rust Macros Doing Real Work: Not Just println!
Why macros feel scary (and why they don’t have to)
If you’ve ever opened a Rust codebase and seen a wall of macro_rules! or mysterious #[derive(...)] attributes, you probably wondered: am I still writing Rust, or did I just wander into a compiler plugin lab? That reaction is pretty common.
The trick is to stop thinking of macros as black magic and start treating them as a very opinionated code generator. They run at compile time, they spit out normal Rust code, and the compiler then checks that code like any other. Once you see the input → expansion → compiled code flow, the fear factor drops pretty fast.
Under the hood, there are two big families you’ll bump into:
- Declarative macros:
macro_rules!and, more recently, macro 2.0. Pattern matching on tokens in, Rust code out. - Procedural macros: functions that take a token stream and return a token stream. These power
#[derive], custom attributes, and function-like macros.
Let’s walk through examples you can actually drop into a project, rather than yet another vec! clone.
A tiny logging macro that you might actually keep
Everyone sees println! first, but real projects usually want structured logging with levels. You can absolutely wire up a logging crate, but suppose you want a dirt-simple macro that automatically includes file and line information.
#[macro_export]
macro_rules! log_info {
(\((\)arg:tt)*) => {{
// In a real app, this might go to a logging backend
eprintln!(
"[INFO] {}:{} - {}",
file!(),
line!(),
format!(\((\)arg)*)
);
}};
}
You’d use it like this:
fn main() {
log_info!("Starting server on port {}", 8080);
}
What actually happens? The macro expands into a normal eprintln! call with file!() and line!() baked in. The compiler never sees a mysterious log_info!; it only sees the expanded Rust code.
Why is this nice in day-to-day work? Because you get consistent, contextual logging without repeating the same file!() and line!() pattern all over the place. And if you later swap eprintln! for a real logging backend, you change it in one place.
Is this overkill for a small script? Sure. But in a service with dozens of modules, it’s actually pretty pleasant.
Killing boilerplate with a tiny try_or_log! helper
Error handling is where Rust shines, but it can also get noisy. Imagine a web handler where you want to log an error and return early if something fails. You could write the same match block ten times. Or you can let a macro handle the pattern.
#[macro_export]
macro_rules! try_or_log {
(\(expr:expr, \)msg:literal) => {{
match $expr {
Ok(val) => val,
Err(err) => {
eprintln!("{}: {}", $msg, err);
return Err(err.into());
}
}
}};
}
Using it in a function:
use std::fs::File;
use std::io::{self, Read};
fn read_config(path: &str) -> Result<String, io::Error> {
let mut file = try_or_log!(File::open(path), "Failed to open config");
let mut buf = String::new();
try_or_log!(file.read_to_string(&mut buf), "Failed to read config");
Ok(buf)
}
This pattern shows up a lot in real code: repeat the same error-handling structure, change the expression and the message. A macro keeps the behavior consistent and makes the intent pretty obvious.
Could you use a helper function instead? Sometimes, yes. But a function can’t return from the caller; a macro can, because it expands right into the caller’s body. That’s the sort of thing macros are actually good at.
Building a tiny DSL with macro_rules!
Every Rust dev eventually sees some mini language built with macros. Testing frameworks, configuration builders, even SQL-like query definitions. You don’t need to build a full-blown framework to see the value.
Imagine you’re wiring up feature flags and you’re tired of typing out the same struct literals. You want something that reads a bit more like a config snippet than raw Rust.
#[derive(Debug)]
struct FeatureFlag {
name: &'static str,
enabled: bool,
}
macro_rules! feature_flags {
( \(( \)name:ident => \(enabled:expr ),* \)(,)? ) => {{
let mut v = Vec::new();
$(
v.push(FeatureFlag {
name: stringify!($name),
enabled: $enabled,
});
)*
v
}};
}
Now you can write:
fn main() {
let flags = feature_flags! {
dark_mode => true,
beta_search => false,
};
for f in flags {
println!("{} = {}", f.name, f.enabled);
}
}
What’s happening here is actually pretty straightforward: the macro pattern matches identifier => expression pairs, and for each pair pushes a FeatureFlag into a Vec. The stringify! macro converts the identifier into a string literal at compile time.
This is the kind of DSL you might keep in a real project. It’s not trying to look like an entirely different language; it just trims the ceremony and keeps the data structure obvious.
When macros beat generics (and when they really don’t)
A common question: why not use generics instead of macros? Fair question.
Generics are great when you want the same logic over different types. Macros shine when you want to generate slightly different code depending on the pattern, or when you need to mess with syntax in ways generics can’t.
Take a simple example: implementing the same trait for multiple primitive types. You could write them all out by hand, or you can lean on a macro.
trait FromEnv {
fn from_env(key: &str) -> Option<Self>
where
Self: Sized;
}
macro_rules! impl_from_env_for_num {
($t:ty) => {
impl FromEnv for $t {
fn from_env(key: &str) -> Option<Self> {
std::env::var(key).ok()?.parse::<$t>().ok()
}
}
};
}
impl_from_env_for_num!(i32);
impl_from_env_for_num!(u64);
impl_from_env_for_num!(f64);
Could you use a blanket generic impl here? Not really, because parse is type-specific and you might want slightly different behavior per type later. The macro keeps the implementations aligned but still lets you tweak them per type if needed.
On the flip side, if you’re just writing a generic fn max<T: Ord>(a: T, b: T) -> T, using a macro instead of a generic function would be, frankly, weird.
A first taste of #[derive] procedural macros
So far we stayed in macro_rules! land. That’s already pretty powerful, but the macros you see most in modern Rust are procedural: #[derive(Serialize)], #[derive(Debug)], and a mountain of crate-specific ones.
Procedural macros live in their own crate type (proc-macro) and work more like compiler plugins: they take a token stream, inspect or transform it, and spit out new code.
A very small (and slightly contrived) derive example is a macro that automatically implements a Hello trait for any struct by printing its type name.
First, the trait in your main crate:
pub trait Hello {
fn hello();
}
Then, in a separate proc-macro crate (say, hello_derive):
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Hello)]
pub fn derive_hello(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = input.ident;
let expanded = quote! {
impl Hello for #name {
fn hello() {
println!("Hello from {}!", stringify!(#name));
}
}
};
TokenStream::from(expanded)
}
Using it in your main crate:
use hello_derive::Hello;
#[derive(Hello)]
struct User;
fn main() {
User::hello(); // prints: Hello from User!
}
Obviously, real-world derives do a lot more than print a name. Serialization libraries like serde inspect the fields, generate code to walk the structure, and handle attributes like #[serde(rename = "...")]. But the pattern is the same: the macro reads your type definition and writes the boring parts for you.
Yes, writing procedural macros means dealing with token streams, syn, and quote, which is, let’s say, not everyone’s favorite weekend activity. But using them is straightforward, and that’s where most Rust developers live: consuming these macros, not authoring them.
Attribute macros that change behavior without changing call sites
Another very common procedural macro flavor is the attribute macro. Instead of creating a trait impl, it wraps or rewrites a function or item you already have.
Think about a web framework where you want to mark a function as an HTTP handler. You might see something like #[get("/users")] above a function. Underneath, that macro is usually registering the function with a router and maybe tweaking the signature.
Here’s a toy example: an attribute macro that logs how long a function took to run.
In a proc-macro crate:
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, ItemFn};
#[proc_macro_attribute]
pub fn measure_time(_attr: TokenStream, item: TokenStream) -> TokenStream {
let input = parse_macro_input!(item as ItemFn);
let name = &input.sig.ident;
let block = &input.block;
let vis = &input.vis;
let sig = &input.sig;
let expanded = quote! {
#vis #sig {
let start = std::time::Instant::now();
let result = (|| #block)();
let duration = start.elapsed();
println!("{} took {:?}", stringify!(#name), duration);
result
}
};
TokenStream::from(expanded)
}
And in your main crate:
use my_macros::measure_time;
#[measure_time]
fn do_work() {
// some heavy computation here
}
fn main() {
do_work();
}
From the caller’s point of view, do_work() is just a normal function. The macro quietly wraps it with timing logic. That’s the kind of thing attribute macros are great at: cross-cutting concerns like logging, metrics, or registration that you don’t want sprinkled manually everywhere.
When macros backfire and make everything worse
At this point it’s tempting to macro all the things. That’s usually when a team ends up with a private macro DSL nobody understands six months later.
A few patterns that tend to age badly:
- Macros that hide control flow in surprising ways (like silently swallowing errors).
- Macros that try to look like a completely different language instead of just smoothing Rust’s edges.
- Huge
macro_rules!blocks with dozens of arms and zero comments.
The usual advice from the Rust community — echoed in documentation and talks from the Rust project and various university courses like those you’ll find at MIT OpenCourseWare — is pretty sensible: start with functions and traits, reach for macros when you hit patterns they simply can’t express cleanly.
If you want a more formal, language-level view, the official Rust Book’s macro chapters are still the go-to reference:
- The Rust Book on macros: https://doc.rust-lang.org/book/
- Rust reference on macros: https://doc.rust-lang.org/reference/macros.html
They won’t write your logging macro for you, but they will keep you honest about how expansion and hygiene really work.
FAQ: questions people actually ask about Rust macros
Do macros make Rust code harder to debug?
They can, but they don’t have to. The compiler shows errors in the expanded code, which sometimes points at macro internals instead of your call site. Most Rust tooling can show you the expanded result (cargo expand is the usual choice), and once you get used to that, debugging macro-heavy code becomes much less painful.
Are macros slower than normal Rust code?
No. Macros run at compile time and generate regular Rust code. The generated code is what gets compiled and optimized. If anything is slow, it’s either your build times (procedural macros can add overhead) or the logic you wrote inside the expansion.
Should I write my own procedural macros, or just use crates like serde?
For most teams, consuming existing macros is the sweet spot. Writing your own procedural macros is worth it when you have a repeated pattern that can’t be expressed nicely with functions, traits, or macro_rules!. If you only need a little syntax sugar, a small declarative macro is usually enough.
Can macros replace code generation scripts?
Sometimes. If you’re generating Rust code from some schema (say, a protocol definition), an external codegen tool might still be cleaner. Macros shine when the information they need is already in the Rust code itself — like struct fields or attributes — and the generation is tightly coupled to the type system.
Are macros safe from a security perspective?
Macros run as part of the compiler, not at runtime in your deployed binary. The main security concern is the same as with any dependency: you’re trusting the crate that provides the macro. Using well-known, audited libraries from reputable sources (for example, widely adopted open source projects or academic code hosted under .org or .edu domains) is just good hygiene.
Rust macros aren’t some mystical layer you’re supposed to suffer through. They’re a tool for making patterns explicit and codebases less repetitive — if you use them with a bit of restraint. Start with small helpers like logging or error handling, peek at what they expand to, and only then move on to fancy derives and attributes.
Once you see them as compile-time code generators you control, not as a separate language bolted on the side, they become a lot less scary — and, honestly, best well worth learning.
Related Topics
Examples of Basic Syntax in Rust: 3 Practical Examples for Beginners
Best examples of defining functions in Rust: practical examples for 2025
Examples of Using Enums in Rust (With Practical Code Snippets)
Real-world examples of examples of testing in Rust
Practical examples of examples of using closures in Rust
Practical examples of examples of file I/O in Rust
Explore More Rust Code Snippets
Discover more examples and insights in this category.
View All Rust Code Snippets