7 min read
On this page

Writing Macros

A macro is a function that runs at compile time, takes quoted code as arguments, and returns quoted code that the compiler substitutes in. The mechanics are not complicated once you have internalized that code is data. The hard parts are hygiene — keeping macro-introduced variables from colliding with caller variables — and resisting the urge to write a macro when a function would do.

Before any of this, the cultural rule: write your code as functions first. Only reach for macros when you genuinely need compile-time generation or syntactic flexibility that functions cannot give you. Most experienced Elixir developers will tell you not to write a macro in your first year. The library code you read is full of macros because library authors are the people who legitimately need them.

defmacro Basics

defmacro defines a macro the same way def defines a function. The difference is what the arguments and return value mean.

defmodule MyMacros do
  defmacro say_hello do
    quote do
      IO.puts("hello")
    end
  end
end

To use it, the caller has to require the module (or import it):

defmodule Greeter do
  require MyMacros

  def go do
    MyMacros.say_hello()
  end
end

At compile time, MyMacros.say_hello() is replaced by the quoted body. The compiled Greeter.go/0 ends up identical to:

def go do
  IO.puts("hello")
end

That is a trivial example — a function would have done the same job. The interesting case is when the macro takes arguments that are themselves code.

Receiving Code as Arguments

When a macro receives an argument, it receives the quoted form, not the evaluated value.

defmodule Debug do
  defmacro inspect_expr(expr) do
    quote do
      result = unquote(expr)
      IO.puts("#{unquote(Macro.to_string(expr))} = #{inspect(result)}")
      result
    end
  end
end
defmodule Demo do
  require Debug

  def go do
    Debug.inspect_expr(1 + 2 * 3)
  end
end

When Demo.go/0 runs, it prints 1 + 2 * 3 = 7 and returns 7. The macro had access to the source text of the expression (via Macro.to_string(expr)) and to its runtime value (via unquote(expr)). A function cannot do this — by the time a function receives an argument, the expression has already been evaluated and the source is gone.

This is the kind of thing macros are good at: capturing the structure of an expression and using it to generate code that knows something about itself.

Reimplementing unless

The classic teaching example. unless is a macro in Kernel, but you could write it yourself:

defmodule MyControl do
  defmacro unless(condition, do: block) do
    quote do
      if !unquote(condition) do
        unquote(block)
      end
    end
  end

  defmacro unless(condition, do: do_block, else: else_block) do
    quote do
      if !unquote(condition) do
        unquote(do_block)
      else
        unquote(else_block)
      end
    end
  end
end

Usage:

defmodule Example do
  import MyControl

  def check(x) do
    unless x > 0 do
      "non-positive"
    else
      "positive"
    end
  end
end

The do and else blocks arrive as quoted forms in the keyword list. The macro splices them inside an if with a negated condition. The compiler then expands if (also a macro) into its own primitives, and so on down to special forms.

There is genuinely no way to write unless as a function — a function would evaluate both the condition and the body before deciding whether to run anything. The control-flow semantics require compile-time substitution.

Macro Hygiene

Hygiene is the rule that variables introduced inside a quote block do not leak into the caller's scope, and vice versa. It is what keeps macros from accidentally clobbering caller variables.

defmodule Hygiene do
  defmacro set_x do
    quote do
      x = 42
    end
  end
end

defmodule Caller do
  require Hygiene

  def go do
    x = 1
    Hygiene.set_x()
    x
  end
end

You might expect Caller.go/0 to return 42 — the macro assigns 42 to x, after all. It actually returns 1. The x inside the macro's quote block is a different variable, even though it has the same name. Elixir tags it with the macro's context so it does not collide with the caller's x.

This is almost always what you want. It means you can write a macro without worrying about which variable names the caller happens to be using.

When you genuinely need to introduce a variable into the caller's scope, use var!:

defmodule Hygiene do
  defmacro set_x do
    quote do
      var!(x) = 42
    end
  end
end

Now Caller.go/0 returns 42. var! is an explicit opt-out of hygiene. It is rare and a code smell unless you are building something like a DSL where the variable name is part of the contract — ExUnit.Case uses var! to make context available inside test blocks, for example.

A Useful Macro: with_logging

Here is a macro that wraps a block of code with start and end log calls. The kind of thing you might genuinely write.

defmodule Tracing do
  defmacro with_logging(label, do: block) do
    quote do
      require Logger
      Logger.info("starting: #{unquote(label)}")
      start = System.monotonic_time(:millisecond)
      result = unquote(block)
      duration = System.monotonic_time(:millisecond) - start
      Logger.info("finished: #{unquote(label)} (#{duration}ms)")
      result
    end
  end
end

A caller writes with_logging "expensive thing" do ... :ok end, and at compile time it expands inline into the logging-wrapped form. The same job can be done with a function that takes an anonymous block — the macro version just buys you do ... end syntax instead of fn -> ... end. That syntactic prettiness is rarely worth a macro in your codebase, which is exactly the trade-off the rule of thumb is about.

Debugging Macros

Macros produce code that the compiler then compiles. When something goes wrong, the error usually points at the expanded code, not at your defmacro. Two tools make this bearable.

Macro.to_string/1 converts AST back to source. Drop a IO.puts(Macro.to_string(ast)) inside your macro before returning it, and when the module compiles you see the actual generated code. This is the single most useful debugging trick for macros.

Macro.expand_once/2 expands a quoted form by one level. Run it in IEx to see what a macro produces:

iex> require MyControl
iex> ast = quote do
...>   MyControl.unless(false, do: :ran)
...> end
iex> Macro.expand_once(ast, __ENV__) |> Macro.to_string()
"if(!false) do\n  :ran\nend"

Macro.expand/2 keeps expanding until it hits primitives. That can be overwhelming for non-trivial macros — expand_once is usually what you want during debugging.

defmacrop and Module Attributes

defmacrop defines a private macro within a module — useful for breaking down a larger macro without exposing the helper. Macros also frequently coordinate with module attributes to accumulate state during compilation.

defmodule RouteBuilder do
  defmacro __using__(_) do
    quote do
      Module.register_attribute(__MODULE__, :routes, accumulate: true)
      import RouteBuilder, only: [route: 2]
      @before_compile RouteBuilder
    end
  end

  defmacro route(path, handler) do
    quote do
      @routes {unquote(path), unquote(handler)}
    end
  end

  defmacro __before_compile__(env) do
    routes = Module.get_attribute(env.module, :routes)

    quote do
      def all_routes, do: unquote(Macro.escape(routes))
    end
  end
end
defmodule MyApp.Router do
  use RouteBuilder

  route "/", HomeController
  route "/about", AboutController
end

MyApp.Router.all_routes()
# [{"/about", AboutController}, {"/", HomeController}]

Each route/2 macro call appends to the @routes accumulating attribute. The @before_compile callback fires at the end of compilation and generates an all_routes/0 function with the collected list baked in. This is essentially how Phoenix's router works under the hood — accumulate routes as the module is read, then generate dispatch code at the end.

Macro.escape/1 is there because the accumulated list contains literal AST that needs to be re-quoted before injection. It is one of those small details you only learn by hitting an obscure compile error and reading the docs.

Common Pitfalls

Writing a macro when a function would work. This is the most common mistake. If your macro never inspects the structure of its arguments and never needs compile-time evaluation, it should probably be a function. The pattern defmacro foo(x), do: quote(do: bar(unquote(x))) is almost always a sign you wanted a function.

Forgetting to require the module. Macros are not callable without require (or import, which implies require). The error message — "you must require Module before invoking the macro" — is usually clear, but it confuses people who expected modules to work the same way for macros as for functions.

Leaking variables with var!. Reaching for var! to "make it work" almost always indicates you are fighting the language. The hygienic version is what callers expect. If you must use var!, document it loudly — caller code will break in surprising ways if the variable name changes.

Using unquote outside quote. Compile error. unquote only makes sense inside a quoted block. If you find yourself wanting to evaluate something at the top of a macro, just use plain Elixir — the macro body is regular code that runs at compile time.

Forgetting that macros change with arity. defmacro foo(x) and defmacro foo(x, y) are different macros. A macro that takes do: and one that takes do:/else: are different too — you need separate clauses for each.

Treating the AST as opaque. It is just nested tuples. You can pattern-match on it, walk it with Macro.prewalk/2 and Macro.postwalk/2, and transform it with regular Elixir code. The AST is data; treat it as data.

Key Takeaways

  • defmacro defines a compile-time function whose arguments and return value are quoted forms. The compiler substitutes the returned AST in place of the call.
  • Macros can inspect the structure of their arguments — including the original source text via Macro.to_string/1 — which functions cannot.
  • Hygiene means variables in quote blocks do not collide with caller variables. var! opts out, but you rarely want to.
  • Macro.to_string/1 and Macro.expand_once/2 are your debugging tools. Print the expanded AST when a macro misbehaves.
  • defmacrop is for private macros within a module. Regular defp is for compile-time helpers that return values to the macro.
  • Module.register_attribute(..., accumulate: true) plus @before_compile is the standard pattern for libraries that collect DSL state and emit code at the end.
  • The rule of thumb is unambiguous: write functions first. Reach for macros only when functions genuinely cannot do the job.