6 min read
On this page

Code as Data

The single insight that unlocks Elixir metaprogramming is that Elixir code is itself an Elixir data structure. There is no separate "macro language" with its own syntax. When the compiler reads your source, it parses it into nested tuples — and those tuples are exactly the values your macros receive and return. Everything else in this topic follows from that one fact.

If you have written Lisp, this will feel familiar. If you have not, it will feel unsettling at first. Stick with it. Once you internalize that a function call is just a three-tuple, the whole machinery stops looking magical.

The quote Macro

quote takes Elixir code and returns its abstract syntax tree (AST) instead of evaluating it.

iex> quote do: 1 + 2
{:+, [context: Elixir, imports: [{1, Kernel}, {2, Kernel}]], [1, 2]}

That tuple is the entire representation of 1 + 2. The shape is always the same:

{atom_or_tuple, metadata, args_or_atom}
  • The first element is the operation. For 1 + 2 it is :+. For foo(x) it is :foo.
  • The second is metadata — line numbers, imports, contexts. You almost never touch this.
  • The third is the arguments, themselves quoted forms.

Try a few more in IEx to get a feel for it:

iex> quote do: foo(1, 2)
{:foo, [], [1, 2]}

iex> quote do: x = 5
{:=, [], [{:x, [], Elixir}, 5]}

iex> quote do: if(x > 0, do: :pos, else: :neg)
{:if, [context: Elixir, imports: [{2, Kernel}]],
 [{:>, [...], [{:x, [], Elixir}, 0]}, [do: :pos, else: :neg]]}

Variables show up as three-tuples too: {:x, [], Elixir}. The third element is the context — Elixir uses it to track hygiene, which we will come back to in the next subtopic.

Literals like integers, floats, atoms, booleans, and small lists quote to themselves:

iex> quote do: 42
42

iex> quote do: :ok
:ok

iex> quote do: [1, 2, 3]
[1, 2, 3]

This is convenient — you can build AST fragments by hand without wrapping every leaf.

Why This Matters

Because code is data, the compiler can transform it before it produces bytecode. A macro is a function that runs at compile time, takes quoted code as input, and returns quoted code as output. The compiler then substitutes the returned AST in place of the original macro call and continues compiling.

This is what lets Phoenix write get "/users", UserController, :index and have it expand into actual function definitions and route table entries. It is what lets Ecto write from u in User, where: u.active and produce a SQL query. It is what lets ExUnit write assert x == 5 and rewrite it into a helpful failure message that prints both sides of the equality.

None of those are language features. They are libraries built on the macro system.

Most of Elixir Is Macros

This is the part that surprises people. def, defmodule, if, unless, defstruct, case, cond, for, with, try, &&, ||, !, in, the operators +, -, *, / — almost everything you think of as Elixir syntax is a macro defined in Kernel or Kernel.SpecialForms.

You can prove it by inspecting:

iex> require Kernel
iex> Kernel.__info__(:macros)
[
  {:!, 1}, {:&&, 2}, {:..//, 3}, {:<>, 2}, {:@, 1}, {:and, 2},
  {:binding, 0}, {:binding, 1}, {:def, 1}, {:def, 2}, {:defdelegate, 2},
  {:defexception, 1}, {:defguard, 1}, {:defguardp, 1}, {:defimpl, 2},
  {:defimpl, 3}, {:defmacro, 1}, {:defmacro, 2}, {:defmacrop, 1},
  {:defmacrop, 2}, {:defmodule, 2}, {:defoverridable, 1}, {:defprotocol, 2},
  {:defstruct, 1}, {:destructure, 2}, {:if, 2}, {:in, 2}, {:is_nil, 1},
  {:is_struct, 1}, {:is_struct, 2}, {:match?, 2}, {:or, 2}, {:raise, 1},
  {:raise, 2}, {:reraise, 2}, {:reraise, 3}, {:sigil_C, 2}, {:sigil_D, 2},
  ...
]

That is a partial list. When you write def foo(x), do: x + 1, the compiler is calling Kernel.def/2 as a macro and expanding it into a lower-level form that adds a function to the current module's compile-time function table.

The Elixir "kernel" — the actual primitive layer — is tiny. There are a handful of special forms the parser knows about directly (=, __block__, quote, unquote, function calls, anonymous functions), and the rest is built on top in Elixir itself. Reading lib/elixir/lib/kernel.ex in the Elixir source is enlightening. It is mostly defmacro definitions.

This is why people say Elixir is a small language. The surface looks big because the standard library is generous, but the part the compiler has to understand intrinsically is small. Everything else is leverage from the macro system.

quote and unquote

quote freezes code into AST. unquote injects a value back into a quoted form. They are duals — you usually need both.

iex> x = 5
5
iex> quote do: 1 + x
{:+, [...], [1, {:x, [], Elixir}]}

That is probably not what you wanted. quote captured the literal name x, not the value 5. To inject the value, you unquote:

iex> quote do: 1 + unquote(x)
{:+, [...], [1, 5]}

Now the AST has 5 baked into it. The rule of thumb: quote is the default, unquote is the escape hatch back to evaluating Elixir at quote time.

There is also unquote_splicing, which splices a list of AST nodes into the surrounding form rather than embedding the list itself:

iex> args = [1, 2, 3]
iex> quote do: foo(unquote(args))
{:foo, [], [[1, 2, 3]]}        # foo([1, 2, 3]) — one argument, a list

iex> quote do: foo(unquote_splicing(args))
{:foo, [], [1, 2, 3]}          # foo(1, 2, 3) — three arguments

You will use unquote_splicing whenever you generate function calls or function definitions from a list at compile time.

Inspecting Quoted Code

Reading raw AST tuples is painful. Macro.to_string/1 converts AST back into source code, which is how you debug macros without losing your mind:

iex> ast = quote do
...>   if x > 0 do
...>     :positive
...>   else
...>     :nonpositive
...>   end
...> end
iex> Macro.to_string(ast)
"if(x > 0) do\n  :positive\nelse\n  :nonpositive\nend"

When a macro misbehaves, the workflow is: capture the AST your macro produced, run it through Macro.to_string/1, and read what the compiler is actually being told to compile. Half of macro debugging is realizing the generated code is not what you thought it was.

A Worked Example: A Tiny Code Generator

Here is quote and unquote doing something useful. Suppose you want to define a function for each entry in a list at compile time.

defmodule Colors do
  for color <- [:red, :green, :blue] do
    def unquote(color)(), do: unquote(Atom.to_string(color))
  end
end

That compiles to:

defmodule Colors do
  def red(), do: "red"
  def green(), do: "green"
  def blue(), do: "blue"
end

The for loop runs at compile time. Each iteration uses unquote to inject the current color into a def form, so three actual function definitions land in the module. At runtime there is no loop, no lookup table — just three plain functions the BEAM has compiled directly.

This is the same pattern Ecto uses when you write a schema. Each field :name, :string call expands into compile-time work that generates accessors, struct fields, and changeset metadata. By the time your code runs, all of it is plain functions on the schema module.

Special Forms

A handful of constructs in Elixir cannot be redefined and are not macros. They are called special forms and live in Kernel.SpecialForms. The list is short:

  • quote and unquote themselves
  • __block__ (multiple expressions in sequence)
  • case, cond, try, receive
  • fn (anonymous function)
  • = (match operator)
  • __aliases__ (module references like Foo.Bar)
  • for and with

You cannot write a macro that produces these from scratch in a meaningful way — they are the substrate everything else compiles down to. When you use quote, the AST you build is in terms of special forms plus calls to other macros and functions.

Common Pitfalls

Confusing quote with defmacro. quote is a tool you use inside any code to capture AST. defmacro is how you declare a function that the compiler will treat as a macro. You will often see them together, but they are separate things.

Forgetting that unquote only works inside quote. Using unquote at the top level of a function raises CompileError. If you need to inject a value, you must be inside a quote do ... end block first.

Believing the metadata field matters when reading AST. It almost never does. When pattern-matching on quoted forms, match {:foo, _meta, args} and ignore the middle slot.

Expecting variables to "just work" across quote boundaries. They will not. The next subtopic on macro hygiene covers this in depth — for now, know that quote do: x and the x in your surrounding code are not the same variable by default.

Reaching for quote to build strings. quote builds AST, not strings. If you want to assemble Elixir source text and compile it later, you want Code.eval_string/1 or Code.string_to_quoted/1 — different tools entirely.

Key Takeaways

  • Elixir code parses into AST tuples of the form {op, metadata, args}. Literals quote to themselves.
  • quote do: expr captures the AST of expr instead of evaluating it. unquote(value) injects a value back into a quoted form.
  • Most of what looks like syntax — def, defmodule, if, unless, defstruct, even && and || — is a macro in Kernel, not a built-in.
  • The actual primitives are special forms in Kernel.SpecialForms: case, cond, fn, =, quote, unquote, __block__, and a few others.
  • Macro.to_string/1 is how you debug AST — it converts quoted code back into readable source.
  • The macro system is what gives libraries like Phoenix, Ecto, and ExUnit their declarative feel. It is leverage, not language magic.