Every “magic” thing in Elixir - Ecto queries that become SQL, Phoenix routes
that dispatch at the speed of pattern matching, ExUnit’s assert that knows
the expression that failed, not just the values - comes from one mechanism.
It is not a framework feature, build step, or plugin system. It is a language
primitive that JavaScript does not have: the macro.
This article explains what macros are, how they work, and why their absence is
the biggest structural difference between writing Elixir and writing
JavaScript. It connects to the Lisp heritage discussed in the
currying article: Elixir inherited
Lisp’s “code is data” idea and gave it a non-parenthesized face. Here, we will
trace the mechanics: the abstract syntax tree, quote and unquote, hygiene,
and the compile-time hooks that make Phoenix and Ecto possible.
The prerequisite: code as a data structure
To metaprogram, see your code as data. Most languages build an abstract syntax tree (AST) internally - the tree the compiler creates from your source before generating bytecode or machine code - but keep it hidden. You never interact with it because the language does not expose it.
Elixir’s creator, José Valim, chose differently. He exposed the AST as a form that Elixir’s own data structures can represent, then gave the language syntax for working with it. As Chris McCord puts it in Metaprogramming in Elixir: “Having the AST accessible by normal Elixir code lets you do very powerful things because you can operate at the level typically reserved only for compilers and language designers.”
That syntax is quote. Quote an expression and Elixir returns its AST as an
Elixir data structure:
iex> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}
iex> quote do: div(10, 2)
{:div, [context: Elixir, import: Kernel], [10, 2]}
1 + 2 is ordinary Elixir source, but Elixir represents it as a three-element
tuple: the function-call atom (:+), metadata, and a list of arguments. Put
that tuple in a variable, pattern match on it, transform it, build another one,
or hand it to the compiler. The compiler works with the same shape your code
produces. There is no separate “compiler world” and “programmer world”.
The structure is uniform, so memorize it. Every Elixir expression breaks down to a three-element tuple, where the first element is an atom naming the call (or a nested tuple for a nested node), the second is metadata about the expression, and the third is the list of arguments. A whole module quotes to nested versions of the same shape:
iex> quote do
...> def hello, do: "World"
...> end
{:def, [context: Elixir, import: Kernel],
[{:hello, [context: Elixir], Elixir}, [do: "World"]]}
Certain literals quote to themselves - atoms, integers, floats, strings,
lists, and two-element tuples of the former. Everything else becomes the
three-tuple form. (%{a: 1} is not a literal and becomes
{:%{}, [], [a: 1]}; Enum becomes {:__aliases__, [alias: false], [:Enum]}.)
This matters when you inject values into quoted code. We will return to it.
The Lisp connection
Elixir’s AST shape is not accidental - it is Lisp’s with different brackets. McCord makes the comparison explicitly:
“In some languages, such as flavors of Lisp, source is written directly as an AST, using parentheses to form expressions. If you look closely, you can see how Elixir operates at a layer just above this format.”
The Lisp (+ (* 2 3) 1) and the Elixir quote do: 2 * 3 + 1 produce
structurally identical trees - {:+, _, [{:*, _, [2, 3]}, 1]} - differing
only in bracket choice. Lisp has you write the AST directly. That gives you
a programmable syntax, but it also means parentheses for everything. Elixir’s
bet, per McCord, was to “separate the syntax from the
AST” so you get “the best of both worlds: a programmable AST with a high-level
syntax to perform all the work.” You write readable Elixir; quote gives you
the tree Lisp would have made you type.
This is the same heritage the currying article
called out when it described Elixir’s quote/unquote as “homoiconicity transplanted into
a non-parenthesized syntax.” Here, that history becomes the mechanism every
framework uses.
unquote: injecting values into the tree
quote captures code as data, but the data does not know about variables in the
surrounding scope. unquote is the escape hatch: it injects a value into a
quoted expression while the quote is being built. McCord’s analogy fits:
“You can think of quote/unquote as string interpolation for code.”
iex> number = 5
iex> quote do: number * 10
{:*, [context: Elixir, import: Kernel], [{:number, [], Elixir}, 10]}
# ↑ the AST of a *reference* to `number`, not the value 5
iex> quote do: unquote(number) * 10
{:*, [context: Elixir, import: Kernel], [5, 10]}
# ↑ now the value 5 is baked into the tree
The first quote produces the AST of “a thing called number times ten”. If you
evaluate it, it fails because there is no function number/0. The second quote
uses unquote to bake the value 5 into the tree. That is the whole job:
quote builds structure, and unquote fills its blanks from the outside world.
There is a sibling, unquote_splicing, for injecting a list of arguments into
a call’s argument list rather than passing one list. It is the macro equivalent
of JavaScript’s spread syntax. Use it when the generated call has a variable
number of arguments.
defmacro: functions that take code, return code
A macro is a function that runs at compile time, receives its arguments as AST (not evaluated values), and returns AST. The compiler replaces the macro call with the returned AST, then keeps expanding until no macros remain. McCord’s summary is the one to remember: “macros receive ASTs as arguments and provide ASTs as return values. By writing macros, you are building ASTs using Elixir’s high-level syntax.”
Here’s the canonical first example, a macro that re-implements unless:
defmodule ControlFlow do
defmacro unless(expression, do: block) do
quote do
if !unquote(expression), do: unquote(block)
end
end
end
# Used like a built-in:
unless 2 == 5, do: "block entered"
#=> "block entered"
At compile time, the compiler sees unless(...) and calls the macro. The macro
receives the unevaluated AST of 2 == 5 and "block entered", not the values
false and "block entered". It builds the AST of if !(<expression>) do <block> end, splicing in the original arguments through unquote, and returns
that AST. The compiler replaces the unless call with it. The result contains
an if, itself a macro, so the compiler expands that into a case expression.
Expansion ends in Kernel.SpecialForms, the small set of irreducible macros
(case, fn, {}, etc.) that cannot be overridden.
The caller writes unless like a keyword. There is no wrapper function, no
callback, and no () => ... thunk. It looks built in. The implementation costs
six lines of code. This is what “Elixir is macros all the way down” means:
defmodule, def, if, in, even Logger.debug, are themselves macros,
built from the same primitive you just used. Elixir’s standard library uses the
same metaprogramming facilities as your code. Language authors get no private
set of powers.
Why this is impossible in JavaScript
This is the root of the asymmetry. JavaScript has no compile phase that the
language itself can hook into. Every call site evaluates arguments eagerly
before the function runs, and code cannot access its own AST. A JavaScript
unless(cond, block) must therefore take block as a thunk - () => ... -
because it cannot receive an unevaluated expression. Callers write
unless(x, () => doThing()), not unless x do doThing() end, and compile-time
checking is unavailable.
JavaScript’s alternatives sit outside the language. Babel and SWC plugins
transform the AST in a separate build toolchain that the language never sees.
babel-plugin-macros is the closest analogue: libraries ship compile-time
transforms imported like modules. But those transforms run in the Node build
process rather than the language compiler, are unhygienic AST splices with no
quote/var! discipline, and are invisible to node myfile.js without a
build step. Proxy and Reflect intercept runtime operations on objects,
not syntax - there is no trap that makes from u in User mean anything.
Decorators (still TC39 Stage 3 as of mid-2026) can wrap classes and members at
definition time; they cannot generate arbitrary code or build a DSL. The table
is stark:
| Capability | JavaScript | Elixir |
|---|---|---|
| In-language compile-time macros | None |
defmacro + quote/unquote |
| Unevaluated arguments at call site | Never (thunks required) | Yes - macros receive raw AST |
| AST access from within the language | Never |
First-class (quote everywhere) |
| Closest macro analogue | babel-plugin-macros (build tool) | Native - same compiler runs the macros |
| Query DSL checked at compile time | No (Prisma/Drizzle: builders + codegen) |
Ecto from parses/validates → query struct |
| Route table | Runtime data structure (Express) | Compiled function clauses (Phoenix) |
| Assertion introspection | Values only (Jest) |
AST + values (ExUnit assert) |
For every row, JavaScript either defers to runtime (Proxy, Jest, Express) or
leaves the language entirely (Babel, codegen, transpiled decorators). Elixir
does this work in one compile phase, and user code can hook into it through
the same compiler used by the standard library.
Hygiene: macros that don’t clobber your variables
A macro that injects arbitrary code, including variable bindings, would be a
footgun: its internal name variable could silently shadow or overwrite yours.
Elixir prevents that with hygiene. Variables, imports, and aliases defined
inside a quoted expression do not leak into the caller’s scope, and the
caller’s definitions do not leak into the macro.
iex> name = "Chris"
iex> Setter.bind_name("Max") # a macro that does var!(name) = "Max"... or does it?
iex> name
"Chris" # ← unchanged: hygiene protected your `name`
When a macro must reach into the caller’s bindings - and legitimate cases exist
-
opt out explicitly with
var!:
defmacro bind_name(string) do
quote do
var!(name) = unquote(string)
end
end
iex> name = "Chris"
iex> Setter.bind_name("Max")
iex> name
"Max" # ← now it overwrote, because var! broke hygiene on purpose
McCord frames the design choice: “Elixir takes the safe approach of requiring
you to explicitly allow a macro to define bindings in the caller’s context.
This design forces you to think about whether violating hygiene is necessary.”
The default is safe; the override is visible and deliberate. There is also a
two-argument form, var!(buffer, Html), that scopes the unhygienic variable to
a specific module’s context so it does not leak into the caller. The book uses
it in its HTML-DSL example to share an internal buffer across macro-generated
blocks without polluting the user’s namespace. Break hygiene when necessary,
but contain the blast radius.
use and __using__: the framework-extension idiom
The most visible metaprogramming in daily Elixir is the use macro. You have
seen use GenServer, use Phoenix.LiveView, use Ecto.Schema, and
use ExUnit.Case. None is special syntax. use SomeModule invokes
SomeModule.__using__/1, a macro that injects code into the calling module at
compile time. McCord calls it “the use macro” that “serves the simple but
powerful purpose of providing a common API for module extension.”
A minimal version, from the book, builds a tiny test framework:
defmodule Assertion do
defmacro __using__(_options) do
quote do
import unquote(__MODULE__) # bring Assertion's macros into scope
Module.register_attribute __MODULE__, :tests, accumulate: true
def run, do: IO.puts("Running the tests...")
end
end
end
defmodule MathTest do
use Assertion # ← expands to the quoted block above
end
When the compiler sees use Assertion inside MathTest, it calls
Assertion.__using__/1. That macro returns the quoted block, which the compiler
injects into MathTest as if the user had typed it. MathTest now has an
imported Assertion API, a registered :tests attribute, and a run/0
function. The user wrote none of that. The “framework magic” of
use GenServer uses the same mechanism: a documented function call during
compilation, not a runtime hook.
The book stresses that use is just a macro - “it feels like an untouchable
keyword, but in reality it’s just a macro that does a bit of code injection.”
That is the design: Elixir stays a small language built by macros, with the
line between “built-in” and “library” blurred on purpose.
Module attributes: compile-time accumulation
The :tests attribute in the example above shows the pattern Phoenix and Ecto
use to build their APIs. Elixir module attributes can be registered with
accumulate: true, which makes each assignment append to a list instead of
overwriting it:
defmacro test(description, do: test_block) do
test_func = String.to_atom(description)
quote do
@tests {unquote(test_func), unquote(description)} # ← accumulates
def unquote(test_func)(), do: unquote(test_block) # ← defines a function
end
end
Each test "adds numbers" do ... end inside a use Assertion module defines a
function and appends an entry to @tests. When the module finishes compiling,
@tests contains the full list of registered tests. Ecto uses this pattern to
accumulate field declarations into a schema’s list of fields. Phoenix uses it
to accumulate get/post declarations into a router’s route table. Each DSL
keyword is a macro that records itself in an accumulated attribute at compile
time.
There is an ordering trap. If a function reads @tests too early - for example,
inside the initial __using__ block - the attribute is still empty because no
test macros have run. Elixir provides @before_compile for this case: a
callback that fires just before compilation finishes, after attribute
accumulation is complete:
defmacro __using__(_options) do
quote do
import unquote(__MODULE__)
Module.register_attribute __MODULE__, :tests, accumulate: true
@before_compile unquote(__MODULE__) # ← register the hook
end
end
defmacro __before_compile__(_env) do
quote do
def run, do: Assertion.Test.run(@tests, __MODULE__) # ← @tests is full now
end
end
__before_compile__ reads the accumulated attribute at the right moment and
generates the final code. This trio - register_attribute(..., accumulate: true)
-
per-DSL-keyword macros that append +
__before_compile__that reads and generates - is the load-bearing pattern behind every Elixir DSL. Once you see it, Ecto schemas and Phoenix routers stop looking like magic. They become the same recognizable structure.
The flagship consequences
This machinery exists for practical reasons. Macros run at compile time, so
Elixir’s flagship libraries catch errors that JavaScript libraries leave until
runtime and turn whole classes of bugs into CompileErrors.
Ecto queries. from u in User, where: u.age > 18, select: u.email is not a
builder API or a string. It is Elixir syntax that the from macro parses and
validates into an %Ecto.Query{} struct at compile time. Malformed query
syntax is a compile error, and there is no string-SQL to inject. A typo such as
u.emial raises at runtime on first query execution rather than at compile time
because field resolution depends on the schema. That is still earlier than in
a JS ORM, where the equivalent typo compiles and fails only against a live query
in production. The adapter generates SQL later, at execution time. Validation
and SQL generation are separate by design.
Phoenix routes. get "/users/:id", UserController, :show expands into
compiled pattern-matching function clauses. The route table is code, not an
interpreted data structure. Dispatch uses BEAM pattern matching over compiled
clauses, not a runtime list scan as in Express. Adding a route is a compile-time
act. The router is as fast as hand-written case statements because it is
hand-written case statements generated by a macro.
ExUnit’s assert. assert x == y receives the assertion’s AST, so a failure
prints both the expression and the values on each side. Jest’s
expect(a).toBe(b) receives only values and cannot show source. The macro reads
its argument and produces a richer error message than a plain function can. A
function sees only evaluated values, never the expression that called it.
The through-line is simple: these features are impossible in the language in
JavaScript. Prisma and Drizzle use builder objects plus a codegen step whose
output the language treats as opaque strings. Express reads a runtime route
table. Jest sees values, not expressions. Each JS framework is JS plus a build
step, and each toolchain has its own transform pipeline. Phoenix, Ecto, and
ExUnit are written in Elixir against the same compiler your application uses.
Learn quote/unquote. That one concept turns the Elixir stack from magic into
mechanism.
The two contexts, and bind_quoted
A macro runs in two contexts. The macro definition context is the code that
runs at compile time when the macro is called, in the macro’s own module. The
caller’s context is the code the macro injects; it runs later in the module
that called use or invoked the macro. McCord puts the risk plainly: “because
macros are all about injecting code, you have to understand the two contexts in
which a macro executes, or you risk generating code in the wrong place.”
The practical trap is double evaluation. If you unquote the same expression
twice in a quoted block - once to log it and once to return it - the caller’s
expression runs twice:
# BUG: remote_api_call.() runs twice
defmacro log(expression) do
quote do
IO.inspect unquote(expression) # ← first evaluation
unquote(expression) # ← second evaluation
end
end
Use bind_quoted instead. It evaluates each binding once and makes it available
as a variable inside the quote:
defmacro log(expression) do
quote bind_quoted: [expression: expression] do
IO.inspect expression # ← variable, not re-evaluated
expression # ← same variable
end
end
bind_quoted is the recommended default for any non-trivial quote block. It
prevents accidental reevaluation and removes the need to sprinkle unquote
everywhere. The trade is that unquote is disabled inside a bind_quoted block
unless you pass unquote: true to quote.
Two more tools complete the everyday toolkit. Macro.escape/1 takes an
arbitrary Elixir value and returns its AST representation when you need to
inject a non-literal, such as a map, into a quoted block. Without it, a map
raises CompileError: invalid quoted expression. The special forms
__CALLER__ and __ENV__ give a macro access to the calling environment at
compile time: the caller’s module, file, line, and bound variable names. Macros
use that information for context-aware error messages, and
@before_compile__ receives its env argument.
When not to write macros
This is the most important section, and McCord opens the book with it as Rule 1: Don’t Write Macros:
“You may hear this rule touted loudly when talking with others about metaprogramming… writing code to produce code requires special care. It’s easy to get caught in our own web of code generation, and many have been bitten by reckless complexity. When taken too far, macros can make programs difficult to debug and reason about. There should always be a clear advantage when we attack problems with metaprogramming. In many cases, standard function definitions are a superior choice if code generation is not required.”
His later guidance is more specific: “Macros should be reserved for
specialized cases where the solution can’t be implemented easily as normal
function definitions. Whenever you’re writing code and reach for defmacro,
stop and ask yourself whether your solution requires code generation.”
The concrete anti-patterns he names are worth knowing. They are the ones that give macros a bad reputation:
Don’t use use as a mixin. A common mistake is using use to inject
functions from another module, essentially treating it as a fancy import.
“The use macro should never be used solely for mix-in style functionality.
Importing functions serves the same purpose without generating code.” If a
function does not need to be generated, just import it.
Don’t inject large amounts of code. When use is needed for code
generation, inject the minimum and delegate to real functions immediately.
“Whenever you’re injecting code, it should be your goal to delegate out of the
caller’s context as soon as possible. This way, your library code stays in your
library, and the injected code is just the bare minimum to call out from the
caller’s context into your library functions.” The benefit is concrete: stack
traces point to real library functions instead of a confusing generated block
inside the caller’s module. The caller’s contract is that generated code is
“simple and fast”. Break it and the whole language becomes harder to debug.
Apply the DSL test. Before building a domain-specific language on macros, ask McCord’s three questions: Can the domain be expressed naturally by macros in Elixir’s syntax (like HTML tags)? Would a DSL make the caller think more or less about solving the problem? Should users of the library have code injected into their context? If the answers do not clearly favor a DSL, use standard functions instead.
There is a tension here with the book’s Rule 2: Use Macros Gratuitously - “it’s important to avoid letting the potential for abuse scare you away from fully exploring Elixir’s macro system… you can’t be afraid to be a little irresponsible while you’re learning.” The rules address different audiences. Rule 2 is for the learner building intuition in a scratch project. Rule 1 is for the library author shipping code that others will debug. In production, make functions the default. Reach for macros when they offer a clear, articulable advantage that functions cannot provide; even then, inject the minimum and delegate the rest.
The honest summary
Metaprogramming in Elixir makes the language’s ecosystem possible, and it rests
on one fact: Elixir code is representable as an Elixir data structure, the
three-element {call, metadata, args} tuple, accessible through quote and
injectable through unquote. A macro is a compile-time function that receives
unevaluated AST and returns AST. The compiler expands macros recursively until
only special forms remain. The AST has the same shape as the code you write,
and expansion runs in the compiler that built the standard library. That is why
the line between “language feature” and “library feature” disappears: def,
if, defmodule, and Logger.debug are macros, and you can write your own
with the same six lines that wrote unless.
The consequences are categorical, not incremental. Ecto validates queries at
compile time because from is a macro that parses its own syntax. Phoenix
dispatches routes at the speed of pattern matching because its route table is
compiled function clauses. ExUnit prints the failing expression because
assert receives its own AST. JavaScript cannot do this in the language: it has
no compile phase the language can hook, no way to receive unevaluated arguments,
and no in-language AST access. JS frameworks therefore depend on external build
tools (Babel, codegen) or use runtime-only metaprogramming (Proxy, Reflect)
that can intercept objects but never syntax.
The discipline matters as much as the power. Macros are a last-resort tool for
cases functions genuinely cannot handle: extending syntax, generating code
from data, catching errors at compile time, or building a DSL that fits a
domain. When you use them, follow McCord’s rules: do not use use as a mixin,
inject the minimum and delegate to real functions, and keep the caller’s
contract - generated code should be simple and fast. Used well,
metaprogramming lets Elixir remain a small language with a vast ecosystem
written in itself. Used badly, it ships a codebase nobody can debug. Phoenix
and your worst macro use the same primitive. Restraint makes the difference.
Where to go next
-
Bits have no meaning: the stored-program bargain
- the machine-level version of this article’s subject: code as data is not a Lisp invention, it is the stored-program bargain, and macros are its governed, safe form.