When a Function Won't Do: Reaching for Racket's Hygienic Macros
Functions can't delay evaluation of their arguments — macros can. A practical look at Racket's define-syntax-rule, hygiene, and the macro stepper, with worked examples and honest trade-offs.
30 Dec 2025, 05:21 UTC

The problem: you can't pass "code that hasn't run yet"
Say you're timing database queries in a Racket application. You want a form like (time-it (expensive-query)) that prints how long the body took. Your first instinct is a function:
(define (time-it thunk)
(define start (current-inexact-milliseconds))
(define result (thunk))
(printf "~a ms\n" (- (current-inexact-milliseconds) start))
result)
But now every call site has to wrap its work in a lambda: (time-it (lambda () (expensive-query))). That lambda noise is exactly what macros exist to remove. A macro lets you write the call as if the timing wrapper were built into the language, because — at expansion time, before the program runs — it effectively is.
The thesis here is simple: Racket's syntax-rules macros are the right tool when you need to control when or whether arguments evaluate, and hygiene means you get that power without the classic macro footguns.
define-syntax-rule: a template with a safety net
For one-pattern transformations, define-syntax-rule is the concise entry point. Here's the timing macro:
(define-syntax-rule (time-it body)
(let ([start (current-inexact-milliseconds)])
(define result body)
(printf "~a ms\n" (- (current-inexact-milliseconds) start))
result))
The first part, (time-it body), is the pattern; the rest is the template. When the compiler sees (time-it (expensive-query)), it substitutes body and produces the let form. Crucially, body is spliced in as unevaluated syntax — it runs exactly once, inside the timing wrapper, at the call site's lexical context.
Now the hygiene part. Notice the template introduces its own binding, start. In old-school macro systems (think C's #define or Common Lisp's defmacro used carelessly), if the caller's code happened to use a variable named start, the macro's binding could capture it — silently changing what the caller's code refers to. Racket's hygienic expansion renames introduced identifiers behind the scenes so start inside the template can never collide with a start at the call site. You get lexical scoping preserved automatically, with no manual gensym bookkeeping.
Seeing the expansion: the macro stepper
The fastest way to build trust in a macro is to watch it expand. In DrRacket, open the macro stepper (Macro Stepper in the Racket menu) on a file containing:
#lang racket
(define-syntax-rule (time-it body)
(let ([start (current-inexact-milliseconds)])
(define result body)
(printf "~a ms\n" (- (current-inexact-milliseconds) start))
result))
(define (expensive-query) (sleep 0.1) 42)
(time-it (expensive-query))
The stepper shows each expansion phase. Look closely at the expanded output: the introduced start and result appear with marks or renamings distinguishing them from any user-level identifiers of the same name. That visual is hygiene made concrete. You can run the same file in the DrRacket REPL (Racket 8.0 or later is a safe assumption; this behavior is long-established) and confirm the call prints a millisecond figure and returns 42.
A second pattern: when one template isn't enough
When you need multiple shapes — say, an optional label for the timing output — graduate to syntax-rules with several clauses:
(define-syntax time-it
(syntax-rules ()
[(time-it body)
(time-it "elapsed" body)]
[(time-it label body)
(let ([start (current-inexact-milliseconds)])
(define result body)
(printf "~a: ~a ms\n" label (- (current-inexact-milliseconds) start))
result)]))
Clauses are tried top to bottom, and the first clause recursively expands into the second. This is the idiomatic way to grow a small DSL: each clause is a pattern, and patterns compose.
The trade-off: debugging moves up a level
Macros are not free. When something goes wrong, the error often points at expanded code the user never wrote, which confuses newcomers. A function composes and stacks traces cleanly; a macro interposes a transformation step you have to reason about. Performance can also surprise you: a template that mentions body twice will evaluate it twice — a classic bug that hygiene does not protect you from. The rule of thumb: if you only need to abstract over values, write a function. Reach for a macro when you need to control evaluation order, introduce bindings for the caller, or build notation that functions can't express.
Try it yourself
Install Racket, open DrRacket, and paste the time-it examples above. Run them, then open the macro stepper and click through the expansion. Then deliberately write the broken version — one that mentions body twice with a side-effecting call — and watch the double evaluation happen. That fifteen-minute exercise teaches more about hygienic macros than any amount of reading, and it gives you a reliable mental model for when a macro earns its place over a plain function.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.