An object-relational mapper (ORM) is a small language inside a host language. Methods like where, order and limit describe database queries without SQL, and the ORM builds them from the host language's ordinary methods and closures.
Clojure builds some of the same abstractions with a tool most languages don't have. Clojure inherited Lisp's macro system, which lets a programmer transform code before the code is evaluated. Programmers can add control structures and small domain-specific languages without modifying the compiler.
This article explains Clojure macros with three things an ORM provides: queries, scopes and transactions. The ORM examples are written in Ruby, in the chained style that ORMs in most languages share. The article then explains what "homoiconicity" means, because the word is often used as a synonym for "code as data" and its definition says something else. The last part compares Clojure's macros with those of Common Lisp, Scheme, Rust and Haskell.
Summary. A Clojure macro receives code as ordinary Clojure data and returns new code for the compiler. Most of what an ORM does needs no macro in any language, because functions, closures and plain data are enough. Macros are for the cases where the evaluation of an expression has to change. A language is homoiconic when a program has the same representation for the programmer who writes it and for the processor that runs it. Clojure is homoiconic to a large degree, and its macros are short because that shared representation is made of ordinary data structures. Compared with Clojure, Common Lisp allows more changes to source syntax, Scheme protects macro authors from more naming mistakes, and Rust macros need more parsing work.
What a macro does that a function cannot
A function receives values. A macro receives code. The difference shows up as soon as you try to write your own conditional.
Clojure has when, which runs its body only if a condition is true:
(when (> temperature 30)
(println "It's hot"))
when looks like a function call, but a function can't do this job. A Clojure function evaluates all of its arguments before the function body runs:
(defn my-when [condition body]
(if condition body))
(my-when false
(println "This still gets printed"))
The message is printed even though the condition is false. Clojure evaluates the println expression first and passes its result to my-when.
A macro receives the unevaluated println expression and builds a new expression around it:
(defmacro my-when [condition & body]
`(if ~condition
(do ~@body)))
(my-when false
(println "This won't be printed"))
The backtick is called syntax-quote. It returns the code that follows as a template instead of evaluating it. Inside the template, ~ inserts one form and ~@ inserts a sequence of forms.
macroexpand-1 shows the code a macro call produces:
(macroexpand-1
'(my-when false
(println "Hello")))
;; => (if false (do (println "Hello")))
The compiler then compiles the if expression in place of the my-when call. Because if only evaluates the branch it takes, the println never runs.
The diagram shows what my-when receives in each case:
Function: the arguments are evaluated, then the function runs
(my-when false (println "hi"))
| |
v v
false nil <- "hi" is printed here
| |
+----+----+
|
v
my-when receives values
Macro: the macro runs first, on the code
(my-when false (println "hi"))
| |
v v
false (println "hi") <- still code, nothing is printed
| |
+----+----+
|
v
my-when returns new code
|
v
(if false (do (println "hi")))
Most languages follow the same rule as Clojure functions, and solve the problem by wrapping the code in a closure. In Ruby, a method call evaluates its arguments first, and a block wraps code so the method can decide whether to run it:
def my_when(condition)
yield if condition
end
my_when(false) { puts "This won't be printed" }
Closures like this block are the reason an ORM needs no macro system, and they come up again in the section on transactions.
An ORM query and its Clojure equivalent
In an ORM, a query for the 20 most recently created active users looks like this:
User.where(active: true)
.order(created_at: :desc)
.limit(20)
Each method returns a query object. The database query runs later, when the records are needed.
In Clojure, HoneySQL builds a similar query:
(require '[honey.sql :as sql]
'[honey.sql.helpers :as h])
(-> (h/select :*)
(h/from :users)
(h/where [:= :active true])
(h/order-by [:created_at :desc])
(h/limit 20))
The result is a Clojure map that describes the query:
{:select [:*]
:from [:users]
:where [:= :active true]
:order-by [[:created_at :desc]]
:limit 20}
Passing the map to sql/format produces a SQL string and its parameters:
["SELECT * FROM users WHERE active = TRUE ORDER BY created_at DESC LIMIT ?" 20]
HoneySQL is not an ORM. It has no model associations, validations or callbacks. It builds SQL from Clojure data.
What the arrow does
The -> in the HoneySQL example is a macro, called the thread-first macro. It inserts each expression as the first argument of the next one. Clojure expands this code:
(-> query
(h/where [:= :active true])
(h/limit 20))
into this nested expression:
(h/limit
(h/where query [:= :active true])
20)
The macro builds the nested expression in two steps:
(-> query
(h/where [:= :active true])
(h/limit 20))
Step 1: query becomes the first argument of h/where
(h/where query [:= :active true])
^^^^^
Step 2: the result becomes the first argument of h/limit
(h/limit (h/where query [:= :active true]) 20)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The -> macro runs no SQL and has no knowledge of SQL. It rearranges Clojure expressions so they read top to bottom. The work is done by h/where and h/limit, which are ordinary functions that receive a query map and return an updated one.
The ORM gets the same readable chain from method calls on the query object. Method call syntax already reads left to right, so no macro is needed.
Scopes: a class method and a Clojure macro
Many ORMs let a model give a query fragment a name, often called a scope:
class User < Model
def self.active
where(active: true)
end
end
User.active.order(created_at: :desc)
ORM documentation sometimes calls class-level declarations such as has_many and validates macros. In Ruby they are class methods that run when the class is defined. They receive evaluated arguments and never rewrite Ruby syntax, so they are not macros in the Lisp sense.
The Clojure equivalent of a scope is a function:
(defn active-users [query]
(h/where query [:= :active true]))
(-> (h/select :*)
(h/from :users)
active-users)
No macro is necessary. Suppose, though, that an application wants every scope declared in the same style. A defscope macro can provide that:
(defmacro defscope [name & steps]
`(defn ~name [query#]
(-> query# ~@steps)))
(defscope active-users
(h/where [:= :active true])
(h/order-by [:created_at :desc]))
The macro fills the slots of its template and returns a regular function definition:
template `(defn ~name [query#] (-> query# ~@steps))
| | |
| | +-- every step, spliced in
| +-- a generated unique symbol
+-- the name, inserted
result (defn active-users [query__1692__auto__]
(-> query__1692__auto__
(h/where [:= :active true])
(h/order-by [:created_at :desc])))
The query# notation asks syntax-quote for a generated unique symbol, so the parameter can't clash with a name used in the steps. The diagram leaves out the namespace prefixes that syntax-quote adds, such as clojure.core/defn.
For one scope, defn is simpler. defscope earns its place only when an application has a larger set of conventions to enforce. The example shows that a programmer can add a new defining form to Clojure in three lines, without changing the compiler.
Conditional queries with cond->
A more common need is a query built from optional filters. With an ORM:
query = User.all
query = query.where(active: filters[:active]) unless filters[:active].nil?
query = query.where(role: filters[:role]) unless filters[:role].nil?
Clojure ships a macro for this pattern, cond->:
(defn user-query [{:keys [active role]}]
(-> (h/select :*)
(h/from :users)
(cond->
(some? active)
(h/where [:= :active active])
(some? role)
(h/where [:= :role role]))))
The query map passes through each pair of condition and step. When the condition is true, cond-> applies the step. When the condition is false, the query passes through unchanged. Calling user-query with both filters produces WHERE (active = FALSE) AND (role = ?), and calling it with an empty map produces SELECT * FROM users.
This example still contains no custom macro. Clojure's built-in macros cover many of the cases where a custom macro looks tempting.
Transactions: a closure and a Clojure macro
In an ORM, a closure marks the code that runs inside a transaction. In Ruby the closure is a block:
db.transaction do
user.update(plan: "pro")
AuditEvent.create(
user: user,
action: "upgraded"
)
end
If an operation raises an exception, the transaction rolls back.
In Clojure, next.jdbc provides with-transaction:
(jdbc/with-transaction [tx ds]
(jdbc/execute! tx
["UPDATE users SET plan = ? WHERE id = ?"
"pro" user-id])
(jdbc/execute! tx
["INSERT INTO audit_events (user_id, action)
VALUES (?, ?)"
user-id "upgraded"]))
with-transaction is a macro. It binds tx to a connection with an open transaction, runs the body, commits if the body returns and rolls back if the body throws.
Clojure doesn't need a macro here either. next.jdbc also provides transact, a function that takes another function, which is the same design as the ORM's block. The macro saves the caller from writing (fn [tx] ...) and makes the binding look like let.
The transaction example shows why comparing languages by macro power alone is misleading. The ORM needs no macro for transactions because a closure already passes code to a method.
A macro that a function cannot replace
The macros so far are conveniences. Each one could be replaced by a function that takes other functions. The go macro from core.async could not.
(go
(let [value (<! input)]
(>! output (inc value))))
go rewrites its body into a state machine. Each channel operation, such as <! and >!, becomes a point where the code can pause and resume later without blocking a thread. Other languages add this behavior to the compiler, as JavaScript did with async and await. In Clojure, a library added it with a macro.
Why Clojure code is easy to transform
Clojure macros are short because Clojure code is made of the same data structures as the rest of the program. Lisp programmers call this property "code as data".
Take one step of the query:
(h/where query [:= :active true])
Clojure evaluates this expression as a function call. A quote in front stops the evaluation:
'(h/where query [:= :active true])
The quoted expression is a list of three elements: the symbol h/where, the symbol query and a vector.
'(h/where query [:= :active true])
list
|
+-- h/where symbol
|
+-- query symbol
|
+-- [:= :active true] vector
|
+-- := keyword
+-- :active keyword
+-- true boolean
The functions that inspect application data also inspect this list:
(first '(h/where query [:= :active true]))
;; => h/where
(last '(h/where query [:= :active true]))
;; => [:= :active true]
The same functions build new code. Here is a complete query expression, stored as data:
(def query-code
'(-> (h/select :*)
(h/from :users)
(h/where [:= :active true])))
concat adds another step to it:
(concat query-code
'((h/limit 20)))
;; => (-> (h/select :*)
;; (h/from :users)
;; (h/where [:= :active true])
;; (h/limit 20))
The result is a new program, built with a sequence function. Passing it to eval returns the query map with :limit 20 included.
Many languages have eval, so eval is not the notable part. The notable part is that first, last and concat were enough to inspect and extend the code. A macro does the same work. It receives forms, returns a new form, and the compiler evaluates the result, so macro authors rarely call eval themselves.
What the reader does
A Clojure source file passes through these stages:
+----------------------------------------+
| Source text in a file |
+----------------------------------------+
|
v
+----------------------------------------+
| The reader turns text into forms and |
| knows nothing about any macro |
+----------------------------------------+
|
v
+----------------------------------------+
| Forms are data: lists, vectors, maps |
| and symbols |
+----------------------------------------+
|
v
+----------------------------------------+
| Macro expansion replaces each macro |
| call with the form its macro returns |
+----------------------------------------+
|
v
+----------------------------------------+
| The compiler turns the expanded forms |
| into JVM bytecode |
+----------------------------------------+
The reader doesn't know what any macro means. It only knows the common structure of Clojure forms. A new macro can therefore add a new construct without adding a rule to the language's grammar.
Other languages can inspect and generate their own code too. Python has the ast module, and Ruby ships the Prism parser. Both return trees of specialized node objects that programmers only meet when they write tooling. Clojure code is made of the lists, vectors and maps that Clojure programmers already use for application data, so no separate API has to be learned.
What homoiconicity means
Discussions of Lisp macros often use "homoiconic" as a synonym for "code as data". The original definition says something narrower. A language is homoiconic when a program has the same representation outside the processor, where the programmer writes and reads it, and inside the processor, where it is executed.
The original definition
Calvin Mooers coined the term for TRAC, a text-processing language. It appears in the 1965 paper "TRAC, A Text-Handling Language", which he wrote with L. Peter Deutsch. TRAC stored each procedure in memory as a string of characters, exactly as the user typed it, and the TRAC processor acted on that string. The paper defines the word in one sentence:
Because TRAC procedures and text have the same representation inside and outside the processor, the term homo-iconic is applicable, from homo meaning the same, and icon meaning representation.
Alan Kay applied the term to Lisp in his 1969 doctoral thesis, "The Reactive Engine". He wrote that Lisp and TRAC "are 'homoiconic' in that their internal and external representations are essentially the same".
The definition makes no claim about data structures. TRAC's shared representation was a string of characters, not a list or a tree.
How the definition applies to Clojure
Outside the processor, a Clojure programmer writes forms such as (h/where query [:= :active true]). Inside, Clojure's evaluation rules are defined on the data that the reader produces from those forms. The Clojure reference says that the language "is defined in terms of the evaluation of data structures and not in terms of the syntax of character streams/files".
Reading the text of a form gives the data, and printing the data gives the text back:
(def text "(h/where query [:= :active true])")
(read-string text)
;; => (h/where query [:= :active true])
(pr-str (read-string text))
;; => "(h/where query [:= :active true])"
The two representations are close, but they are not identical. A source file is text, and the reader drops comments and expands shorthand, so 'x becomes (quote x). The Clojure compiler also turns each form into JVM bytecode, so the running program is not a list.
The essay Don't Say "Homoiconic" raises both objections for Lisp in general. It concludes that homoiconicity is a matter of degree, and that a Lisp is homoiconic to a large degree because its semantics are defined on the forms the programmer writes. Its author also suggests that naming those properties directly is clearer than using the word. Python is a useful contrast. A Python program is text outside the interpreter and bytecode inside it, and the language is defined by a grammar over text. Python is not homoiconic, even though the ast module lets a program inspect and build Python code.
The diagram compares the three languages:
what the programmer what the processor
writes acts on
+----------------------+ +----------------------+
TRAC | string of characters | == same == | string of characters |
+----------------------+ +----------------------+
+----------------------+ +----------------------+
Clojure | forms, as text | ~~ close ~~ | forms, as data |
+----------------------+ +----------------------+
+----------------------+ +----------------------+
Python | text | =/= differ =/= | bytecode |
+----------------------+ +----------------------+
Why "code as data" is a different claim
"Code as data" is a broader phrase. It also describes first-class functions, reflection and calling eval on a string, and none of those is homoiconicity.
Two languages separate the ideas. TRAC is homoiconic, and its programs are plain text with no data structure to take apart. Python programs can handle code as data through ast, and Python is not homoiconic.
Clojure has both properties, and its macros depend on both. Because the internal and external representations match, a macro receives code in the shape the programmer wrote it. Because that representation is built from lists, vectors, maps and symbols, first, concat and syntax-quote are enough to rewrite it.
Clojure's own documentation uses the loose meaning. The reader reference calls "homoiconic" "a fancy term describing the fact that Clojure programs are represented by Clojure data structures". That sentence describes code as data. This article uses "homoiconic" only in Mooers's sense.
How Clojure compares with other languages
Clojure is one of several languages with macros. The languages differ in how they represent code, which transformations they permit, and how much work a transformation takes.
| Language | Macro approach | Main trade-off |
|---|---|---|
| Clojure | Functions over ordinary Clojure data | Short macros, fixed reader syntax, partial hygiene |
| Common Lisp | Functions over Lisp forms, plus reader macros | Most freedom, and the author must prevent variable capture |
| Scheme | Hygienic syntax transformers | Automatic protection against variable capture |
| Rust | macro_rules! patterns and procedural macros over token streams |
Capable, and procedural macros usually need a parsing library |
| Haskell | Template Haskell | Capable, and needed less often because evaluation is lazy |
| Ruby | No macros, only blocks and runtime metaprogramming | Syntax stays fixed, and blocks cover most uses, including the ORM examples above |
The table describes design differences. It is not a ranking.
Common Lisp: more freedom
Common Lisp and Clojure share the central idea. Code is represented by ordinary language objects, and macros transform those objects.
Common Lisp goes further with reader macros. The reader is the stage that converts source text into data, and Common Lisp lets a library change how the reader interprets characters. A library can introduce new character-level syntax.
Clojure deliberately has no general mechanism for extending the reader. It offers tagged literals, such as #inst "2026-11-14", and applications can register their own tags. A tag only changes how the single form after it is interpreted, so the text of the file still reads as standard Clojure.
The reason is practical. If any library can change how source files are parsed, a reader may have to load that library before the code makes sense. Clojure gives up some freedom so that all Clojure source uses the same syntax.
Common Lisp also has macrolet and symbol macros, which have no direct equivalent in Clojure's core library.
Scheme: safer macros by default
Macros can capture variables by accident. Suppose a macro introduces a temporary variable called result, and the code around the macro call also uses a variable called result. Depending on how the macro builds its code, one can shadow the other. Avoiding this problem is called macro hygiene.
Common Lisp leaves hygiene to the macro author. Scheme's syntax-rules and syntax-case systems handle it automatically by tracking the lexical scope of each identifier.
Clojure sits between the two. It is not fully hygienic, but syntax-quote prevents the most common mistakes:
(defmacro log-value [expr]
`(let [result# ~expr]
(println "Value:" result#)
result#))
Syntax-quote replaces result# with a generated unique symbol, as it did with query# in defscope. It also resolves other symbols to their namespaces, so let in the template becomes clojure.core/let and a local variable named let at the call site can't change its meaning.
Rust: similar capabilities, more machinery
Rust has two kinds of macros. Declarative macro_rules! macros match patterns and generate code. Procedural macros are Rust functions that receive a token stream and return a token stream.
A token stream is structured, with nested groups for brackets, but it is not parsed into Rust expressions, types and declarations. Authors usually add the syn library to parse the stream and the quote library to generate the output. In Clojure the reader has already produced lists, symbols, vectors and maps, so that layer of work doesn't exist.
Rust macros can do as much as Clojure macros. The difference is that a Clojure macro is written like any other Clojure function, while a Rust procedural macro lives in its own crate and uses its own libraries.
Rust also splits on hygiene. macro_rules! is hygienic for local variables and labels, and procedural macros are unhygienic.
Haskell: fewer reasons to use macros
Haskell has Template Haskell for compile-time code generation, and it also needs macros less often.
The reason is lazy evaluation. A Haskell function can receive an argument without evaluating it, and the argument is evaluated only when its value is needed. The my-when function that failed in Clojure works as an ordinary function in Haskell.
Haskell and Ruby make the same point from different directions. How powerful a macro system is and how often a language needs one are separate questions. Lazy evaluation in Haskell and blocks in Ruby remove many of the cases that require a macro in Clojure.
When to write a macro
Clojure's usual advice is to prefer a function unless a macro is necessary. Most query composition works with functions. HoneySQL is a good example, because its queries are maps and ordinary map operations can change them.
A macro is necessary when the code has to change how expressions are evaluated or introduce a new binding form, as when, with-transaction and go do.
Macros have costs:
- Generated code is harder to debug, because the code that runs differs from the code in the source file.
- A careless macro can evaluate an argument more than once or introduce an unexpected variable.
- A macro is not a value. It can't be passed to
mapor stored in a data structure the way a function can. - A codebase gets harder to read when every library brings its own small language.
Representing code as data also doesn't make a macro system good on its own. It makes code convenient to inspect and transform. It doesn't provide hygiene, readable error messages or debugging tools. Each language has to design those separately, which is why Clojure, Common Lisp and Scheme share the code-as-data idea and still differ so much.
An ORM is a useful reference point. It shows how far a carefully designed API can go with methods and closures alone. Clojure reaches the same place with functions and data. Macros are available for the cases where functions and data are not enough. Those macros stay short because a macro receives the code as the programmer wrote it, in data structures that ordinary Clojure functions can take apart and rebuild.
Sources
- Clojure: Macros. Official reference for macro expansion and the built-in macros
- Clojure: The Reader. How source text becomes Clojure data, including syntax-quote and tagged literals
- Clojure as a dialect of Lisp. Code as data and the design of Clojure's macro system
- Clojure FAQ. Includes the rationale for not allowing user-defined reader macros
- HoneySQL documentation
- next.jdbc documentation
- The Rust Reference: Procedural Macros. Token streams and procedural macro hygiene
- The Scheme Programming Language: Syntactic Extension.
syntax-rulesandsyntax-case - GHC User's Guide: Template Haskell
- TRAC, A Text-Handling Language. Mooers and Deutsch, 1965. The paper that defines "homoiconic"
- The Reactive Engine. Alan Kay's 1969 thesis, which applies the term to Lisp
- Don't Say "Homoiconic". The original definition, the definitions that conflict with it, and a reply from L. Peter Deutsch
- Lobsters discussion of Let Over Lambda. The discussion that prompted this article