Metaprogramming in Elixir: Writing Code That Writes Code
A practical introduction to macros, quote, unquote, compile-time code generation, and domain-specific languages
Most applications are written as instructions for a computer to execute.
Metaprogramming adds another layer: we write programs that inspect, transform, or generate other programs.
That description can make metaprogramming sound more mysterious than it is. In Elixir, it is built on one relatively simple idea:
Elixir code can be represented as ordinary Elixir data.
Once code becomes data, it can be inspected, modified, and returned to the compiler as new code.
This capability powers many constructs that Elixir developers use every day, including:
ifunlessdefdefmoduleuse- ExUnit assertions
- Ecto schemas and migrations
- Phoenix routing
This article explains how Elixir metaprogramming works, how to create macros, and when macros are actually the right tool.
What Is Metaprogramming?
Metaprogramming is the practice of writing code that operates on code.
In Elixir, this usually means creating a macro that:
- Receives code as an Abstract Syntax Tree.
- Inspects or transforms that tree.
- Returns another syntax tree.
- Allows the compiler to compile the returned code.
A normal function receives evaluated values:
def add(left, right) do
left + right
endCalling it evaluates the arguments before the function executes:
add(1, 2)A macro receives the expressions themselves before they are evaluated.
defmacro some_macro(expression) do
# expression is syntax, not its runtime result
endThis allows a macro to understand not only the value produced by an expression, but also how that expression was written.
That distinction is the foundation of metaprogramming in Elixir.
A Useful Mental Model
A simplified Elixir compilation pipeline looks like this:
Source code
↓
Abstract Syntax Tree
↓
Macro expansion
↓
Expanded Elixir code
↓
BEAM bytecodeMacros run during compilation. They generate or transform the code that will eventually run inside the BEAM virtual machine.
This means that macros are not runtime utilities. They are part of the process that creates the runtime program.
Code Is Represented as Data
Elixir represents most expressions using three-element tuples:
{operation, metadata, arguments}Consider this expression:
1 + 2We can convert it into its internal representation with quote:
ast =
quote do
1 + 2
endIO.inspect(ast)The result will look roughly like this:
{:+, [context: Elixir, imports: [{1, Kernel}, {2, Kernel}]], [1, 2]}The three parts represent:
:+ The operation
[...] Compiler metadata
[1, 2] Arguments passed to the operationThe precise metadata may vary, but the basic structure remains the same.
Elixir provides Macro.to_string/1 when we want to convert an AST back into readable code:
ast
|> Macro.to_string()
|> IO.puts()Output:
1 + 2This ability to move between code and data is what makes macros possible.
Understanding quote
The quote construct prevents an expression from being evaluated immediately. Instead, it returns the expression’s syntax tree.
quoted =
quote do
Enum.sum([1, 2, 3])
endAt this stage, Enum.sum/1 has not been called. We only have data representing the function call.
IO.puts(Macro.to_string(quoted))Output:
Enum.sum([1, 2, 3])You can think of quote as saying:
Treat this code as data.
Macros normally use quote to construct the code they want to return to the compiler.
Understanding unquote
While quote turns code into data, unquote inserts an external value or syntax tree into quoted code.
number = 10quoted =
quote do
unquote(number) * 2
endIO.puts(Macro.to_string(quoted))
Output:
10 * 2Without unquote, the quoted expression would refer to a variable named number. With unquote, the current value of number is inserted into the generated syntax tree.
The relationship can be summarized as:
quote → enter the world of syntax trees
unquote → inject something into that syntax treeMost Elixir macros are built by combining these two operations.
Writing a First Macro
A useful example is a debugging macro that prints both an expression and its result.
defmodule Debug do
defmacro debug(expression) do
source = Macro.to_string(expression)quote do
result = unquote(expression) IO.inspect(
result,
label: unquote(source)
) result
end
end
end
We can use it from another module:
defmodule Calculator do
require Debug def total(prices) do
Debug.debug(Enum.sum(prices))
end
endCalling the function:
Calculator.total([100, 200, 300])Produces output similar to:
Enum.sum(prices): 600And the function still returns:
600A regular function could print the value 600, but it would not automatically know that the original expression was written as:
Enum.sum(prices)The macro knows because it receives the expression before evaluation.
Why require Is Necessary
Macros must be available during compilation.
When a module calls a macro from another module, it normally needs to require that module:
require DebugThis tells the compiler that the module contains macros that may need to be expanded.
Macro Expansion
A macro call is not preserved as an ordinary runtime function call. The compiler replaces it with the code returned by the macro.
For example:
unless authenticated? do
redirect_to_login()
endunless is a macro. During compilation, it expands into lower-level conditional code, eventually producing a structure similar to a case expression.
We can inspect macro expansion with the Macro module:
quoted =
quote do
unless authenticated? do
redirect_to_login()
end
endexpanded = Macro.expand(quoted, __ENV__)expanded
|> Macro.to_string()
|> IO.puts()
The expanded form is usually less readable than the original code. That is intentional.
Macros provide a convenient interface while generating the more explicit code required by the compiler.
This is one of the major benefits of metaprogramming: developers can work with a concise abstraction without repeatedly writing its underlying implementation.
Compile Time and Runtime
Understanding the difference between compile time and runtime is essential when working with macros.
Consider this module:
defmodule CompileAndRun do
IO.puts("This runs while the module is compiled") def run do
IO.puts("This runs when the function is called")
end
endThe expression directly inside the module body runs while the module is being compiled:
IO.puts("This runs while the module is compiled")The code inside run/0 executes later:
CompileAndRun.run()Macros operate primarily in the first phase. They generate functions and expressions that may execute in the second phase.
A common source of confusion is accidentally expecting runtime data to exist during macro expansion. Runtime values are generally unavailable because the application has not started executing yet.
use and __using__/1
The use keyword is another macro.
When we write:
use SomeModuleElixir effectively invokes:
SomeModule.__using__(options)The target module can define a __using__/1 macro to inject imports, aliases, attributes, functions, behaviours, or other configuration into the calling module.
A small example:
defmodule Logging do
defmacro __using__(_options) do
quote do
require Logger def log_info(message) do
Logger.info(message)
end
end
end
endAnother module can use it:
defmodule PaymentService do
use Logging def process do
log_info("Processing payment")
end
endThe log_info/1 function is generated inside PaymentService during compilation.
This pattern is used throughout the Elixir ecosystem. It allows libraries and frameworks to provide a declarative setup experience.
However, injected code can also make a module harder to understand. A developer reading PaymentService cannot see the implementation of log_info/1 without inspecting Logging.__using__/1.
For that reason, use should provide a clear and predictable contract.
Building a Small DSL
A Domain-Specific Language, or DSL, is an interface designed around the vocabulary of a particular problem.
Ecto migrations are a familiar example:
create table(:messages) do
add :status, :string
endThe code reads like a description of a database migration rather than a series of low-level function calls.
We can build a small schema DSL to understand the underlying mechanism.
defmodule MiniSchema do
defmacro __using__(_options) do
quote do
import MiniSchema, only: [field: 1, field: 2]Module.register_attribute(
__MODULE__,
:fields,
accumulate: true
) @before_compile MiniSchema
end
end defmacro field(name, options \\ []) do
quote do
@fields {unquote(name), unquote(options)}
end
end defmacro __before_compile__(environment) do
fields =
environment.module
|> Module.get_attribute(:fields)
|> Enum.reverse() quote do
def __fields__ do
unquote(Macro.escape(fields))
end
end
end
end
We can now define a module using a declarative syntax:
defmodule User do
use MiniSchema field :name, required: true
field :email, required: true
field :age
endThe generated function exposes the collected field definitions:
User.__fields__()Result:
[
{:name, [required: true]},
{:email, [required: true]},
{:age, []}
]Several metaprogramming features are working together here:
use MiniSchemainvokesMiniSchema.__using__/1.- A module attribute is registered to collect field definitions.
- The
fieldmacro stores each declaration. @before_compileruns a callback before compilation finishes.- The callback generates the final
__fields__/0function.
This is the same general architecture behind many Elixir DSLs, although production libraries add significantly more validation, error reporting, and compiler integration.
Macro Hygiene
Generated code introduces a risk: variables created inside a macro could accidentally conflict with variables in the caller.
Elixir protects against this through hygienic macros.
Consider the earlier debugging macro:
quote do
result = unquote(expression)
IO.inspect(result)
result
endThe result variable belongs to the macro’s generated context. It will not normally overwrite a variable named result in the calling function.
This allows macros to create temporary variables safely.
Elixir provides tools such as var!/1 for intentionally accessing variables from the caller’s context, but bypassing hygiene should be rare. It makes macros more difficult to understand and easier to break.
As a default rule:
Let Elixir’s macro hygiene work unless there is a specific reason to cross the caller boundary.
Avoid Evaluating Expressions More Than Once
A macro must be careful not to insert the same expression multiple times.
This macro is unsafe:
defmacro duplicate(expression) do
quote do
{unquote(expression), unquote(expression)}
end
endCalling it with a pure value appears harmless:
duplicate(10)But consider an expression with a side effect:
duplicate(IO.gets("Enter a value: "))The expression would run twice.
A safer implementation evaluates it once:
defmacro duplicate(expression) do
quote do
value = unquote(expression)
{value, value}
end
endBecause the generated value variable is hygienic, it will not normally conflict with variables in the caller.
This is an important rule for macro authors:
Never repeat an unquoted expression unless repeated evaluation is intentional.
Why Use Macros?
Macros are useful when a function cannot provide the required abstraction.
1. You need access to the original syntax
The debugging macro needed the textual representation of an expression:
Enum.sum(prices)A normal function would receive only the result.
2. You need to generate repeated declarations
A library might generate multiple related functions from a small configuration:
field :status, values: [:draft, :published]From that declaration, a macro could generate validation, casting, serialization, and documentation functions.
3. You are creating a declarative DSL
Framework APIs such as routing, database schemas, migrations, tests, and validations often become more readable when expressed declaratively.
4. You need compile-time validation
A macro can inspect its arguments during compilation and raise a clear error before the application starts.
defmacro only_positive(number) when is_integer(number) and number > 0 do
quote do
unquote(number)
end
endInvalid literal input can be rejected while compiling rather than discovered later in production.
5. You are removing structural boilerplate
Macros can generate predictable code that would otherwise need to be repeated across many modules.
The important word is structural. Macros are useful for repeated program structure, not merely repeated business logic.
When Not to Use Macros
Macros are powerful, but they increase the number of concepts a reader must understand.
Before writing a macro, consider these alternatives:
- A normal function
- A higher-order function
- A behaviour
- A protocol
- A data structure
- A module attribute
- Code generation outside the compilation process
A function should be preferred when the problem can be solved with runtime values:
def calculate_total(items) do
Enum.sum(items)
endTurning ordinary application logic into macros usually creates unnecessary complexity.
Macros can also introduce several costs:
- More difficult debugging
- Less obvious control flow
- Larger compile-time dependencies
- Longer recompilation times
- Confusing error messages
- Hidden code injection
- Tighter coupling between modules
A useful rule is:
Use a function unless you specifically need to transform syntax or generate code during compilation.
Macros Should Make the Caller Simpler
A macro may be internally complicated, but its public interface should make the calling code easier to understand.
Good macro-based APIs usually have these qualities:
- They use vocabulary from the problem domain.
- Their generated behaviour is predictable.
- Compile-time errors point to the caller’s code.
- They avoid silently changing unrelated module behaviour.
- Their documentation explains what code is generated.
- Developers can understand the common case without reading the implementation.
Ecto migrations are effective because this:
create table(:users) do
add :email, :string, null: false
endcommunicates intent more clearly than manually constructing a low-level migration data structure.
A macro that saves five lines but requires an hour to understand is not a useful abstraction.
Practical Guidelines
Before introducing a macro, ask:
Can this be a function?
When the answer is yes, use the function.
Does the implementation need the caller’s syntax?
Macros are justified when the structure of the expression matters, not only its resulting value.
Is compile-time generation providing real value?
Compile-time validation, generated declarations, and well-designed DSLs can provide meaningful benefits.
Will the generated code be understandable?
Use tools such as Macro.to_string/1, Macro.expand_once/2, and Macro.expand/2 to inspect the result.
Is the macro doing too much?
Small, focused macros are easier to maintain than large macros that inject entire application architectures.
Will errors remain useful?
A good macro should fail with a message that helps the developer fix the declaration that caused the problem.
Final Thoughts
Metaprogramming is one of Elixir’s most distinctive features.
Because Elixir represents code using ordinary data structures, developers can inspect and transform code using the same pattern matching and functional programming techniques used elsewhere in the language.
The essential concepts are:
quote Convert code into an AST
unquote Insert data or syntax into an AST
defmacro Define a compile-time transformation
require Make another module's macros available
use Invoke a module's __using__/1 macroMacros make it possible to build expressive APIs, remove structural boilerplate, validate declarations during compilation, and create domain-specific languages.
But macros should not be the default solution.
The best Elixir code usually consists of small functions, explicit data transformations, behaviours, and protocols. Macros should be introduced only when those tools cannot provide the required abstraction.
When used carefully, a macro does not merely reduce the amount of code we write. It gives us a better language for describing the problem.
About Me:
I write about web development, functional programming, developer tools, graphics, and experimental software projects.
LinkedIn: linkedin.com/in/biomathcode
Website: coolhead.in
