Metaprogramming in Elixir: code that writes code

LLM-authored, human-reviewed

Foundations

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

Related exercises

← Back to articles