def Is Not a Function. It’s Not Even a Macro.
Every so often a question comes along that seems too basic to be interesting, and turns out to be exactly the opposite. “Is def a function or a macro?” is one of those questions. The honest answer is: neither. And once you sit with why, you end up staring straight at the floor Clojure is built on.
The trap of the obvious answer
If you’ve been writing Clojure for a while, your instinct probably says “macro.” After all, def looks like it’s doing something at compile time — it takes a raw symbol, x, and doesn’t evaluate it the way a normal function would evaluate its arguments. That’s macro-like behavior, right?
Except it isn’t, and here’s the tell: macros are things you can expand. Try it:
(macroexpand '(def x 1))
;=> (def x 1)
Nothing happens. You get the same form back, unchanged. A real macro — when, ->, defn — rewrites itself into something else when you expand it. def refuses to rewrite into anything, because there’s nowhere lower to go. It’s already at the bottom.
That bottom has a name: special form.
Three categories, not two
It’s tempting to think of Clojure code as just “functions and macros,” but there’s a third, smaller, more fundamental category sitting underneath both:
-
Functions are ordinary values. You can pass them around, store them,
applythem, take theirclass. Nothing about them is syntactically special —mapis a value like5or"hello"is a value. -
Macros are code-rewriting rules. They exist in terms of other code. Every macro, when you peel back the expansion, eventually bottoms out in function calls and special forms.
whenis a macro; expand it and you’ll findifwaiting underneath. -
Special forms are the primitives the evaluator itself understands. They’re not defined in Clojure — they’re hardcoded into the compiler.
def,if,do,quote,fn*,let*,loop*,recur,throw,try,var,new,set!, the dot special form — this is the closed, small set everything else is built from.
def has to be in that third bucket because of what it does: it creates a Var and binds it into a namespace, using the symbol itself, unevaluated, as the name — with full knowledge of which namespace you’re currently compiling in. No ordinary function can do that, because ordinary functions evaluate all their arguments before they ever get a look at them. By the time a function saw x, it’d already have been evaluated to whatever x currently means (or it’d throw, since x isn’t defined yet — that’s the whole point of def).
You can ask Clojure directly, instead of reasoning it out:
(special-symbol? 'def) ;=> true
So how do you tell the other two apart?
Once you accept that special forms are their own thing, a natural follow-up shows up: fine, but how do I check whether some other symbol is a function or a macro? Is there a macro? to go with special-symbol?
There isn’t one built into clojure.core — which surprised me a little — but it’s a one-liner, because macro-ness is just metadata on the Var:
(:macro (meta #'when)) ;=> true
(:macro (meta #'map)) ;=> nil
That’s the whole trick. Wrap it in a function if you want a name for it:
(defn macro? [sym]
(:macro (meta (resolve sym))))
Functions are checked differently, and the difference matters conceptually: macros are a property of the symbol/Var, but functions are a property of the value the symbol resolves to. So you resolve first, then ask the value what it is:
(fn? @(resolve 'map)) ;=> true
fn? is the direct check. There’s also ifn?, which is broader — it’s true for anything invocable, including keywords and sets used as lookup functions, not just genuine fn values.
Putting the whole picture together
Every top-level symbol in Clojure falls into exactly one of three buckets, and you can check each one explicitly:
| Category | Check |
|---|---|
| Special form | (special-symbol? sym) |
| Macro | (:macro (meta (resolve sym))) |
| Function (or other value) | (fn? @(resolve sym)) |
Which gives you a nice little classifier:
(defn classify [sym]
(cond
(special-symbol? sym) :special-form
(:macro (meta (resolve sym))) :macro
(fn? @(resolve sym)) :function
:else :value))
(classify 'def) ;=> :special-form
(classify 'when) ;=> :macro
(classify 'map) ;=> :function
(classify 'nil) ;=> :value (resolve fails here actually, since nil isn't a var)
One honest caveat: resolve only works on symbols that name Vars in some namespace. Local bindings, literals, and destructured names will throw or return nil. This classifier is for top-level, namespace-resident names — not arbitrary forms you might type at a REPL.
Why this is worth caring about
It would be easy to file this under trivia. I don’t think it is. The three-tier structure — special forms at the bottom, macros built on top of them, functions as ordinary values sitting alongside — is the whole reason Clojure’s syntax stays so small while its expressive power doesn’t. defn, let, ->>, cond, nearly everything you reach for daily, is a macro that eventually expands down into that tiny, closed set of nineteen-ish special forms. Understanding where the floor is — and that def lives on it — is understanding why the language holds together at all.