10 min read
On this page

When to Use Macros

The Elixir community has a phrase for this: "Macros are the last resort." It comes from José Valim, the language's creator, and it is repeated by the Phoenix and Ecto authors in talks, in docs, and in code reviews. The libraries that look the most macro-heavy from the outside are written by people who treat macros with suspicion on the inside.

The reason is not philosophical squeamishness. It is that macros impose real costs — on readers, on debuggers, on refactoring tools — and those costs compound. Most of the time you can pay them off with a function and lose nothing. Sometimes you cannot, and that is when macros earn their keep.

When Macros Earn Their Keep

There are four cases where a macro is unambiguously the right tool. Everything else is a judgment call that usually goes the other way.

Building a DSL

A domain-specific language is a small grammar for expressing one kind of thing. Phoenix routes, Ecto schemas, ExUnit tests, Absinthe GraphQL schemas, Plug.Builder pipelines, Oban worker definitions — these are all DSLs implemented as macro libraries. The DSL feels declarative because it is — by the time runtime starts, the DSL has been compiled into ordinary code that happens to behave like the declarations.

Phoenix routes are the canonical example:

defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
  end

  scope "/", MyAppWeb do
    pipe_through :browser

    get "/", PageController, :index
    get "/users/:id", UserController, :show
    post "/users", UserController, :create
  end
end

get, post, scope, pipeline, pipe_through, plug — every one of those is a macro. The get "/", PageController, :index line expands to code that registers the route in a module attribute, then a @before_compile callback at the end emits a call/2 function with a case statement that dispatches to the right controller.

You could not write this with functions. Functions would evaluate their arguments at runtime, requiring a separate registration step. The DSL works because the entire router is processed at compile time and emitted as efficient dispatch code. A request to /users/42 runs through a generated function clause, not a runtime lookup.

Ecto schemas are the same pattern:

defmodule MyApp.User do
  use Ecto.Schema

  schema "users" do
    field :email, :string
    field :name, :string
    field :age, :integer

    has_many :posts, MyApp.Post
    timestamps()
  end
end

Each field call appends to a compile-time list of fields. The schema/2 macro reads that list at the end and emits a defstruct, a __schema__/1 function that returns metadata, a __changeset__/0 that drives validation, and various accessors. By the time your code runs, %MyApp.User{} is a regular struct and the schema metadata is in plain functions.

ExUnit is the third example most Elixir developers see daily:

defmodule MyApp.UserTest do
  use ExUnit.Case

  test "creates a user" do
    assert MyApp.create_user(%{name: "Alice"}).name == "Alice"
  end
end

test "creates a user" do ... end expands into a function definition with a generated name, registered in a list of tests on the module. assert is a macro that inspects the AST of its argument so that on failure it can print "Expected MyApp.create_user(%{name: "Alice"}).name to equal "Alice", got "Bob"" — a message a function-based assertion library cannot produce because by the time the function runs, the structure is gone.

Absinthe's GraphQL schema definitions, Plug.Builder's pipeline composition, Oban's worker DSL with use Oban.Worker, queue: :default — they all follow the same pattern.

Generating Boilerplate at Compile Time

When you need to define N similar functions from a list, a macro (or a for loop using unquote inside defmodule) lets you do it without runtime overhead.

defmodule HTTP.StatusCodes do
  @statuses [
    {200, :ok, "OK"},
    {201, :created, "Created"},
    {204, :no_content, "No Content"},
    {400, :bad_request, "Bad Request"},
    {404, :not_found, "Not Found"},
    {500, :internal_server_error, "Internal Server Error"}
  ]

  for {code, atom, message} <- @statuses do
    def reason(unquote(code)), do: unquote(message)
    def atom(unquote(code)), do: unquote(atom)
    def code(unquote(atom)), do: unquote(code)
  end
end

That generates 18 function clauses at compile time. At runtime there is no list lookup, no case statement — the BEAM compiles each clause into its own dispatch entry. HTTP.StatusCodes.code(:not_found) is a direct match, as fast as any function call gets.

Plug's status code module does exactly this. So does Ecto's adapter module that maps Postgres error codes to atoms. The alternative — a function with a giant case or a lookup map — works fine, but the compile-time-generated version is both faster and clearer in intent.

Enforcing Compile-Time Correctness

Some validations can only happen at compile time because they need source structure, not runtime values.

ExUnit's assert does this. So does Ecto's from macro, which parses query syntax and emits errors like "unknown field :emial" at compile time rather than at runtime. So does Logger — Logger.debug("foo: #{expensive()}") is a macro that wraps the entire expression so expensive() is not called if debug logging is disabled.

The pattern: capture the expression's structure, check it for problems or wrap it in conditional execution, emit code that does the right thing. None of this works as a function.

Bringing Capability Into the Caller

use Something is the standard way for a library to install capability into a caller's module. use GenServer installs the GenServer behaviour, default implementations, and the right imports. use Phoenix.LiveView installs the LiveView callbacks, render plumbing, and a few hundred lines of generated code.

This needs to be a macro because it has to inject code into the caller's module — including def definitions, @behaviour declarations, and module attributes. A function cannot do that.

When Macros Are the Wrong Tool

For every legitimate macro, there are ten that should have been a function. The pattern is recognizable.

"It just looks cleaner"

defmacro double(x) do
  quote do
    unquote(x) * 2
  end
end

This macro is worse than def double(x), do: x * 2 in every way. It compiles to the same code. It does not gain you anything. It costs you: callers must require your module, the function does not show up in Module.__info__(:functions), you cannot pass it as a function reference, and someone reading the call site cannot tell it is a macro until they look it up.

If your macro's body is quote do: unquote(arg) operator value, you wanted a function.

"To save typing"

defmacro l(x), do: quote(do: Logger.info(unquote(x)))

You have saved nine characters and made the codebase worse. Now grep for Logger.info does not find this call site. Now a junior reader has to figure out what l/1 means. Now if you ever want to swap Logger for something else, you have one more place to update.

The 5-character savings is never worth it.

"Because the existing library does it that way"

Phoenix and Ecto's public APIs are macro-heavy, and beginners often imitate the style without recognizing why those libraries use macros. The reason Phoenix routes are a macro is because the router needs to compile a dispatch table at compile time. The reason your business logic for "calculate the discount for an order" is not a macro is because it is just a function that takes data and returns data.

Look at the internal code in Phoenix or Ecto. Most of it is plain modules and plain functions. The macros are concentrated at the API surface where they specifically need to be.

The Costs of Macros

The reason for restraint is that macros impose specific, measurable costs.

Harder to read. A function call tells you exactly what runs. A macro call expands into something else, and the reader needs to know what to even look up. New team members hit this constantly.

Harder to debug. When a macro generates code that fails, the stack trace points at the expanded code, not at your defmacro. Line numbers may be off. You end up running Macro.to_string/1 over the expansion to see what the compiler is actually seeing.

Harder to refactor. Tools like rename and find-references work great on functions. They are weaker on macros because the call sites do not look like function calls at the AST level. Renaming a function called by 200 macro expansions can mean grepping by hand.

Less composable. You cannot pass a macro to Enum.map. You cannot capture it as &MyMod.thing/1. You cannot partially apply it. The moment you want first-class function semantics, you regret the macro.

Slower compilation. Every macro expansion is work the compiler does. A module heavy in macro calls (a large Phoenix router, an Ecto schema with hundreds of fields) takes noticeably longer to compile. This is not usually a big deal but it adds up in large codebases.

What defmodule Actually Does

To make the cost concrete, here is what defmodule MyMod do ... end does at compile time:

  1. Parses the body into AST.
  2. Creates a compilation context — a fresh module being built.
  3. Walks the body in order. For each top-level form:
    • If it is @something value, registers the attribute.
    • If it is def name(...) do ... end, expands def (which is itself a macro) and adds the function to the module's function table.
    • If it is use Other, expands to require Other; Other.__using__(opts), runs the resulting code which usually injects more defs, imports, and attributes.
    • If it is anything else (for, if, function calls), evaluates it at compile time. This is how for x <- list, do: def ... can generate function definitions.
  4. Runs any @before_compile callbacks, which can inject still more code.
  5. Compiles the assembled function table into BEAM bytecode and writes a .beam file.

defmodule is itself a macro that wraps all of that. So is def. So is @. Walking through a single module definition involves dozens of macro expansions before any of your code-as-written reaches the compiler. This is the substrate libraries like Phoenix and Ecto leverage — they hook into the same compilation pipeline that defmodule and def use.

Knowing this changes how you read Elixir. A schema definition is not declarative magic — it is a defmodule body that happens to contain macro calls that emit defs and @s. The "magic" is just the compiler doing what it always does, with the library's macros providing the inputs.

A Practical Decision Tree

When you are tempted to write a macro, work through this in order:

  1. Can a function do it? If yes, write the function. Stop.
  2. Can a function plus a behaviour or protocol do it? If yes, do that. Stop.
  3. Is it really about generating code at compile time, not just about saving typing? If no, write the function. Stop.
  4. Is the code-as-data aspect essential — do you need the structure of the expression, not just its value? If no, write the function. Stop.
  5. Has someone else built a library that solves this with their macros? If yes, use theirs. Stop.
  6. Now you can consider writing a macro. Write the smallest possible one. Document what it expands to. Ship a function-based alternative if you can.

Most of the time you stop at step 1.

Common Pitfalls

Imitating library style without library needs. Just because Phoenix uses macros for its router does not mean your app's business logic should. Their problem and yours are different problems.

Building DSLs for things that are not really domain-specific. If your "DSL" is just function calls dressed up to look like keywords, callers gain nothing and you have given up everything functions offer. Many internal "DSLs" in real codebases would have been better off as plain functions taking plain maps.

Macros that generate macros. When you find yourself writing a macro whose body contains another defmacro, step away from the keyboard. This is almost always a sign that the problem decomposes into smaller, function-shaped pieces.

Hidden dependencies via use. A use that injects three imports, two @behaviours, and a few @before_compile callbacks creates dependencies that no static analysis can fully track. Document what your __using__ does. Better, do less in it.

Skipping the function version "for performance." A function call on the BEAM is around a nanosecond. The macro-versus-function decision is essentially never about performance. It is about whether the work has to happen at compile time. If you are reaching for a macro to avoid a function call, you are optimizing the wrong thing.

Key Takeaways

  • Macros earn their keep in four places: DSLs, compile-time boilerplate generation, compile-time correctness checks, and injecting capability via use. Everything else is suspect.
  • Phoenix routes, Ecto schemas, ExUnit tests, Absinthe, Plug.Builder, and Oban workers are all macro DSLs that compile to plain functions and structs.
  • The costs are real: harder to read, harder to debug, harder to refactor, less composable, slower to compile.
  • defmodule and def are themselves macros. Library DSLs hook into the same compilation pipeline they use, which is why they feel native.
  • The community wisdom — "macros are the last resort," "don't write macros until your second year" — is not gatekeeping. It is the accumulated experience of people who have maintained macro-heavy codebases.
  • When in doubt, write the function. You can always replace it with a macro later if you genuinely need to. The reverse is much harder.