Function vs Macros
Functions and macros both let you name and reuse logic, but they operate at different layers: functions run at execution time on values, macros run at compile time on code itself. Pick functions for almost everything; reach for macros only when you genuinely need to bend the language.
The short answer
Function over Macros for most cases. Functions are typed, debuggable, composable values that respect scope and evaluation order.
- Pick Function if want reusable, testable, debuggable logic — which is 95% of the time. Functions are first-class values, respect lexical scope, evaluate arguments once, and your debugger and type checker actually understand them
- Pick Macros if need to do something a function literally cannot: control evaluation (lazy/short-circuit), capture source as data, generate boilerplate at compile time, or invent new syntax (Lisp, Rust, Elixir, C). That is a narrow, deliberate need
- Also consider: Hygiene and tooling. An unhygienic C-preprocessor macro will capture variables and break your debugger; a good Rust/Lisp macro is hygienic but still opaque to readers. If a function can do it, a macro doing it is showing off, not engineering.
— Nice Pick, opinionated tool recommendations
What they actually are
A function is a runtime abstraction: it takes evaluated values, runs its body, returns a value. It exists as a thing you can pass around, store, and inspect. A macro is a compile-time abstraction: it takes unevaluated code (tokens or AST), rewrites it, and the result is what actually compiles. That single difference — code-in vs values-in — explains every tradeoff that follows. Functions can't see the syntax of their arguments; macros can't see runtime values. C macros are crude text substitution with no type awareness, which is why MAX(a++, b) evaluates a++ twice and ruins your afternoon. Lisp and Rust macros are structured AST transforms, far safer, but still run before your program exists. If you don't need to manipulate code as code, you don't need a macro. That's the whole test, and most code fails it cleanly in favor of functions.
Debuggability and tooling
This is where macros lose decisively for everyday work. A function has a name, a stack frame, a signature your IDE autocompletes, and a body your debugger steps through line by line. When it breaks, the trace points at it. A macro expands before any of that exists. Your stepper lands in generated code you never wrote, error messages cite line numbers that don't map to your source, and MACRO_FOO is invisible to grep-for-callers in any structured sense. C preprocessor errors are legendary precisely because the compiler sees post-expansion garbage and blames the wrong line. Rust's macro_rules! errors improved a lot, but "recursion limit reached while expanding" is still not a sentence a function ever makes you read. Every macro you add is a tax on everyone who later debugs near it. Functions keep the map and the territory the same shape.
Performance and the inlining myth
The classic argument for macros — "no call overhead, it's inlined" — was real in 1989 and is mostly dead now. Modern compilers inline hot functions aggressively; inline, LTO, and JIT tiering erase the gap. Choosing a macro for speed today is usually cargo-cult micro-optimization that buys you nanoseconds and costs you readability. Where macros still legitimately win on performance is compile-time work: generating lookup tables, specializing code per type, eliminating runtime dispatch entirely. That's a different claim than "faster calls" — it's "no calls at all because the work happened during compilation." Real, but specialized. Functions, meanwhile, give you predictable runtime cost you can profile and reason about. If you reach for a macro and your justification is "avoids a function call," you've been measuring the wrong thing. Profile first; you'll almost always find the function was free.
When a macro is the right call
I don't say "it depends," so here's the exact line. Use a macro when a function provably can't express the thing. Cases: controlling evaluation — assert!, unless, lazy and/or, logging that skips formatting when the level is off. Capturing source for diagnostics — Rust's println! checking format args at compile time, or dbg! printing the expression text. Eliminating boilerplate the language gives you no other hook for — derive macros, ORM/serde codegen, test-case generation. Inventing DSL syntax in Lisp where macros are the language's whole superpower. Outside those, a macro is ego. Even inside them, make it hygienic, keep it small, document the expansion, and prefer a function-plus-thin-macro wrapper so the real logic stays testable. The discipline: write the function first, and only promote to a macro when you hit a wall the function physically can't climb.
Quick Comparison
| Factor | Function | Macros |
|---|---|---|
| Evaluation time | Runtime — operates on evaluated values | Compile time — operates on source code/AST |
| Debuggability | Named frames, steppable, clean stack traces | Expands away; traces point at generated code |
| Type safety & tooling | Typed, autocompleted, first-class values | Often untyped text/AST; weak IDE support |
| Control over evaluation | Args always evaluated, exactly once | Can be lazy, short-circuit, or capture syntax |
| Boilerplate / codegen power | Limited to runtime composition | Generates code, DSLs, derive impls at compile time |
The Verdict
Use Function if: You want reusable, testable, debuggable logic — which is 95% of the time. Functions are first-class values, respect lexical scope, evaluate arguments once, and your debugger and type checker actually understand them.
Use Macros if: You need to do something a function literally cannot: control evaluation (lazy/short-circuit), capture source as data, generate boilerplate at compile time, or invent new syntax (Lisp, Rust, Elixir, C). That is a narrow, deliberate need.
Consider: Hygiene and tooling. An unhygienic C-preprocessor macro will capture variables and break your debugger; a good Rust/Lisp macro is hygienic but still opaque to readers. If a function can do it, a macro doing it is showing off, not engineering.
Functions are typed, debuggable, composable values that respect scope and evaluation order. Macros are powerful but expensive: they fracture tooling, hide control flow, and turn every reader into a metaprogramming archaeologist. The default that keeps a codebase legible is the function.
Related Comparisons
Disagree? nice@nicepick.dev