Metaprogramming
What Is Metaprogramming?
Section titled “What Is Metaprogramming?”Metaprogramming in Elixir is the ability to write code that generates or transforms code at compile time. The primary mechanism for this is the macro system, which allows you to manipulate the Abstract Syntax Tree (AST) of Elixir code before it is compiled.
Elixir”s macro system is similar to Lisp’s but with important differences:
- Macros operate on Elixir’s own AST representation, not S-expressions
- Macros are hygienic by default (they do not accidentally capture or overwrite variables)
- Macros are compiled, not interpreted
- The boundary between macros and functions is explicit
The AST
Section titled “The AST”Every piece of Elixir code is represented internally as an AST. The AST is a three-element tuple:
{atom, metadata, arguments}- First element: An atom representing the operation (e.g.,
:+,:def,:if) - Second element: A keyword list of metadata (line numbers, context, etc.)
- Third element: A list of arguments (which may themselves be AST tuples)
Basic AST Examples
Section titled “Basic AST Examples”iex> quote do: 1 + 2{:+, [context: Elixir, imports: [{2, Kernel}]], [1, 2]}
iex> quote do: "hello""hello"## Literals represent themselves (they are not tuples)
iex> quote do: x{:x, [], Elixir}## Variables are three-element tuples: {name, meta, context}
iex> quote do: foo(1, 2){:foo, [], [1, 2]}
iex> quote do: 1 + 2 * 3{:+, [], [{:+, [context: Elixir, imports: [{2, Kernel}]], [1, {:+, [context: Elixir, imports: [{2, Kernel}]], [2, 3]}}]}# Note: Elixir optimizes this at parse time
iex> quote do: [1, 2, 3][1, 2, 3]# Lists represent themselves
iex> quote do: {a, b}{:{}, [], [{:a, [], Elixir}, {:b, [], Elixir}]}# Tuples larger than 2 elements are wrapped in :{} for disambiguationInspecting the AST
Section titled “Inspecting the AST”iex> ast = quote do...> if x > 0 do...> :positive...> else...> :negative...> end...> end
iex> Macro.to_string(ast)"if(x() > 0, do: :positive, else: :negative)"
iex> Macro.to_string(ast) |> IO.puts()if(x() > 0) do :positiveelse :negativeendquote and unquote
Section titled “quote and unquote”quote converts Elixir code into its AST representation. It is the fundamental building block of metaprogramming:
iex> ast = quote do...> name = "Alice"...> "Hello, #{name}!"...> end{{:name, [], Elixir}, "Hello, Alice!"}
iex> quote do: defmodule Foo do...> def bar, do: :baz...> end{:defmodule, [context: Elixir, imports: [{2, Kernel}]], [{:__aliases__, [alias: false], [:Foo]}, [do: {:def, [context: Elixir, imports: [{2, Kernel}]], [{:bar, [], Elixir}, [do: :baz]]}]]}unquote
Section titled “unquote”unquote injects a runtime value into a quoted expression. It is the bridge between the macro’s runtime context and the generated AST:
defmodule MyMacros do defmacro define_getter(name) do quote do def unquote(name)(), do: fetch_value(unquote(name)) end endend
require MyMacrosMyMacros.define_getter(:username)# Expands to: def username(), do: fetch_value(:username)
# unquote with expressionsdefmacro add_logging(function_name, body) do quote do def unquote(function_name)(args) do IO.puts("Calling #{unquote(function_name)}") result = unquote(body) IO.puts("Result: #{inspect(result)}") result end endendunquote_splicing
Section titled “unquote_splicing”unquote_splicing inserts a list of AST nodes into a parent structure, flattening the list:
defmacro define_functions(names) do quote do unquote_splicing( for name <- names do quote do def unquote(name)(), do: unquote(name) end end ) endend
require MyMacrosMyMacros.define_functions([:foo, :bar, :baz])# Expands to:# def foo(), do: :foo# def bar(), do: :bar# def baz(), do: :bazWithout unquote_splicing, inserting a list would wrap it in an extra list layer:
# WRONG: this creates a nested listquote do defmodule MyModule do unquote(list_of_defs) # the defs are wrapped in a list endend
# RIGHT: unquote_splicing flattens the list into the parentquote do defmodule MyModule do unquote_splicing(list_of_defs) endenddefmacro and defmacrop
Section titled “defmacro and defmacrop”defmacro
Section titled “defmacro”defmacro defines a macro that is expanded at compile time. When the compiler encounters a macro call, it expands it into the generated AST before compilation continues:
defmodule MyMacros do defmacro unless(condition, do: do_block, else: else_block) do quote do if unquote(condition) do unquote(else_block || nil) else unquote(do_block) end end endend
# Before compilation:require MyMacrosMyMacros.unless false do IO.puts("This runs because condition is false")else IO.puts("This does NOT run")end
# After macro expansion (what the compiler sees):if false do nilelse IO.puts("This runs because condition is false")enddefmacrop
Section titled “defmacrop”defmacrop defines a private macro, callable only within the defining module:
defmodule Builder do defmacrop build_clause(name, value) do quote do def unquote(name)(), do: unquote(value) end end
defmacro build_all(clauses) do for {name, value} <- clauses do build_clause(name, value) end endendWhen to Use Macros
Section titled “When to Use Macros”Use macros only when:
- You need to create new syntactic constructs (e.g.,
if,unless,defrecord) - You need to inject code based on module attributes at compile time
- You are implementing a DSL (Domain Specific Language)
- You need to perform compile-time code analysis or generation
Prefer regular functions when:
- The code can be written as a normal function call
- You do not need to manipulate the AST
- Performance does not require compile-time expansion
- Readability is more important than reducing boilerplate
# Good macro use: DSLdefmigration "create_users" do create_table "users" do add :name, :string, null: false add :email, :string, null: false add :age, :integer, default: 0 timestamps() endend
# Bad macro use: simple function# DON'T do this:defmacro add(a, b), do: quote do: unquote(a) + unquote(b)# DO this instead:def add(a, b), do: a + bMacros vs Functions
Section titled “Macros vs Functions”The key distinction: functions evaluate their arguments at the call site, macros receive unevaluated AST as arguments.
defmodule Debug do # Function: arguments are evaluated before the function runs def inspect_arg(arg) do IO.puts("Value: #{inspect(arg)}") arg end
# Macro: arguments are NOT evaluated -- they are AST defmacro debug_expr(expr) do string = Macro.to_string(expr) quote do IO.puts("Expression: #{unquote(string)}") IO.puts("Value: #{inspect(unquote(expr))}") unquote(expr) end endend
require Debug
# Function: 1 + 2 is evaluated to 3 before being passedDebug.inspect_arg(1 + 2)# Value: 3
# Macro: 1 + 2 is passed as AST, both expression and value shownDebug.debug_expr(1 + 2)# Expression: 1 + 2# Value: 3The Macro Module
Section titled “The Macro Module”The Macro module provides utilities for working with AST:
Macro.escape/1
Section titled “Macro.escape/1”Converts an Elixir value into its AST representation:
iex> Macro.escape([1, 2, 3])[1, 2, 3]# Simple values are their own AST
iex> Macro.escape(%{a: 1}){:%{}, [], [a: 1]}# Maps need escaping to become AST
iex> Macro.escape({1, 2, 3}){:{}, [], [1, 2, 3]}# Large tuples need wrapping
# Useful in macros when injecting runtime valuesdefmacro create_map(pairs) do escaped = Macro.escape(pairs) quote do: unquote(escaped)endMacro.expand/2
Section titled “Macro.expand/2”Expands a macro or expression to its final form:
iex> require Loggeriex> Macro.expand(quote(do: Logger.info("hi")), __ENV__){:ok, {:info, [context: Logger, imports: [{1, Kernel}]], ["hi", []]}}Macro.to_string/2
Section titled “Macro.to_string/2”Converts an AST back to source code string:
iex> Macro.to_string(quote do: if(x, do: y, else: z))"if(x, do: y, else: z)"
iex> ast = quote do: Enum.map([1, 2, 3], &(&1 * 2))iex> Macro.to_string(ast)"Enum.map([1, 2, 3], &(&1 * 2))"Macro.var/2
Section titled “Macro.var/2”Creates a variable AST node:
iex> Macro.var(:x, __MODULE__){:x, [], MyModule}# Creates a variable :x in the context of MyModuleMacro.validate/1
Section titled “Macro.validate/1”Validates that a quoted expression is a valid AST:
iex> Macro.validate(quote do: 1 + 2):okiex> Macro.validate({:bad, 1, 2, 3}){:error, :invalid_ast}Compile-Time vs Runtime
Section titled “Compile-Time vs Runtime”Understanding the Boundary
Section titled “Understanding the Boundary”Macros execute at compile time. They have access to:
- Module name and attributes
- Environment information (
__MODULE__,__CALLER__,__ENV__) - Other macros (via
require)
Macros do NOT have access to:
- Runtime values (user input, database results, etc.)
- Functions defined in other modules at runtime
- Process state
defmodule Example do @version "1.0.0"
# @version is available at compile time defmacro version, do: @version
# This works: compile-time value defmacro version_string do quote do "MyApp v" <> unquote(@version) end endendCALLER and ENV
Section titled “CALLER and ENV”__CALLER__ in a macro gives access to the calling environment (where the macro is invoked). __ENV__ gives access to the current environment (where the macro is defined).
defmodule Tracer do defmacro trace(expr) do file = __CALLER__.file line = __CALLER__.line
quote do result = unquote(expr) IO.puts("#{unquote(file)}:#{unquote(line)}: #{unquote(Macro.to_string(expr))} = #{inspect(result)}") result end endendusing Macro
Section titled “using Macro”The use macro calls __using__/1 on the target module. This is the standard mechanism for injecting code when a module is used:
defmodule LoggerBackend do defmacro __using__(_opts) do quote do import LoggerBackend, only: [log: 1, log: 2]
@before_compile LoggerBackend end end
defmacro __before_compile__(env) do module_functions = Module.definitions_in(env.module, :def) count = length(module_functions)
quote do def __function_count__, do: unquote(count) end endend
defmodule MyApp.Service do use LoggerBackend
def process(data), do: data def validate(data), do: is_map(data)end
# MyApp.Service now has:# - imported log/1 and log/2 functions# - __function_count__/0 returning 2Hygiene
Section titled “Hygiene”Variable Hygiene
Section titled “Variable Hygiene”Elixir macros are hygienic by default. Variables defined inside a macro do not leak into the caller’s scope, and the caller’s variables are not accessible inside the macro:
defmodule Hygienic do defmacro create_variable do quote do x = 10 x end end
defmacro create_unhygienic do quote do var!(x) = 10 var!(x) end endend
require Hygienic
x = 1Hygienic.create_variable()# Returns 10, but x in caller's scope is still 1
x = 1Hygienic.create_unhygienic()# Returns 10, and x in caller's scope is NOW 10
# bind_quoted preserves hygiene while allowing variable injectiondefmacro safe_log(var) do quote bind_quoted: [var: var] do IO.puts("Value: #{inspect(var)}") endendvar! and bind_quoted
Section titled “var! and bind_quoted”var! breaks hygiene, allowing a macro to access or set the caller’s variables:
defmacro set_caller_var(name, value) do quote do var!(unquote(name)) = unquote(value) endend
# In the caller's scope:set_caller_var(:count, 42)# count is now 42 in the caller's scopebind_quoted safely injects values into a quoted block while preserving hygiene:
defmacro create_getter(key) do quote bind_quoted: [key: key] do def get(), do: Map.get(config(), key) endendPractical Macro Examples
Section titled “Practical Macro Examples”Implementing defrecord (simplified)
Section titled “Implementing defrecord (simplified)”defmodule RecordDef do defmacro defrecord(name, fields) do field_atoms = Keyword.keys(fields)
quote do defstruct unquote(field_atoms)
def new(attrs \\\\ []) do struct(__MODULE__, attrs) end
def get(record, key) do Map.get(record, key) end
unquote_splicing( for {field, default} <- fields do quote do def unquote(field)(record) do Map.get(record, unquote(field), unquote(Macro.escape(default))) end
def unquote(:"set_#{field}")(record, value) do Map.put(record, unquote(field), value) end end end ) end endend
require RecordDef
RecordDef.defrecord(User, name: "Unknown", age: 0)
# Generates:# defstruct [:name, :age]# def new(attrs \\ []), do: struct(__MODULE__, attrs)# def get(record, key), do: Map.get(record, key)# def name(record), do: Map.get(record, :name, "Unknown")# def set_name(record, value), do: Map.put(record, :name, value)# def age(record), do: Map.get(record, :age, 0)# def set_age(record, value), do: Map.put(record, :age, value)Assertion Macro
Section titled “Assertion Macro”defmodule Assertion do defmacro assert({operator, _, [left, right]} = expr) do left_str = Macro.to_string(left) right_str = Macro.to_string(right)
quote bind_quoted: [left: left, right: right, left_str: left_str, right_str: right_str, operator: operator] do result = apply(Kernel, operator, [left, right])
unless result do raise """ Assertion failed: #{left_str} #{operator} #{right_str} left: #{inspect(left)} right: #{inspect(right)} """ end
result end endend
require AssertionAssertion.assert(1 + 2 == 3)# Passes silently
Assertion.assert(1 + 2 == 5)# Raises: Assertion failed: 1 + 2 == 5, left: 3, right: 5Routing DSL Macro
Section titled “Routing DSL Macro”defmodule Router do defmacro __using__(_opts) do quote do import Router, only: [get: 2, post: 2, put: 2, delete: 2] @routes [] @before_compile Router end end
defmacro __before_compile__(_env) do quote do def routes, do: @routes end end
defmacro get(path, handler) do quote do @routes [{:get, unquote(path), unquote(handler)} | @routes] end end
defmacro post(path, handler) do quote do @routes [{:post, unquote(path), unquote(handler)} | @routes] end end
defmacro put(path, handler) do quote do @routes [{:put, unquote(path), unquote(handler)} | @routes] end end
defmacro delete(path, handler) do quote do @routes [{:delete, unquote(path), unquote(handler)} | @routes] end endend
defmodule MyRouter do use Router get "/users", UserController get "/users/:id", UserController post "/users", UserControllerend@compile Attributes
Section titled “@compile Attributes”Elixir provides @compile attributes for controlling compilation behavior:
defmodule MyModule do # Remove debug info for smaller BEAM files @compile {:debug_info, false}
# Enable inline expansion (aggressive inlining) @compile :inline_list_funcs
# Warn on unused variables @compile :warn_unused_vars
# Specific function inlining @compile {:inline, my_func: 1, other_func: 2}endProtocol Consolidation
Section titled “Protocol Consolidation”Protocol consolidation merges all protocol implementations into a single module for faster dispatch:
# mix.exs configurationdef project do [ # ... consolidate_protocols: Mix.env() != :test ]end
# Manual consolidationProtocol.consolidate(Size, [List, Map, Tuple])# Writes a consolidated .beam fileConsolidation is the default in production builds. It eliminates the dispatch overhead of looking up implementations at runtime.
Intuition
Section titled “Intuition”Macros are code-generating assembly lines: In Elixir, quote is the blueprint that captures the shape of code without building it. unquote is the instruction that says “put this specific part here.” A macro is an assembly line that takes blueprints as input and produces new blueprints as output — all at compile time. Hygiene is the safety railing that prevents the assembly line from accidentally mixing up parts from different batches.
Why it matters: Metaprogramming eliminates boilerplate by generating repetitive code at compile time. The use macro is how Elixir libraries inject behavior into your modules — when you write use GenServer, a macro expands into all the callback definitions you need.
The key insight: Macros operate on AST (abstract syntax trees), not text. This means they understand code structure, not just string patterns, making them safer and more composable than text-based code generation.
Summary
Section titled “Summary”Elixir’s metaprogramming system is powerful but should be used judiciously:
quoteconverts code to AST (a three-element tuple{atom, meta, args})unquoteinjects runtime values into quoted expressionsunquote_splicingflattens a list of AST nodes into a parent structuredefmacrodefines compile-time code-generating functions- Macros receive unevaluated AST; functions receive evaluated values
- Hygiene prevents variable name collisions between macros and callers
var!breaks hygiene for intentional variable sharingbind_quotedsafely injects values while preserving hygiene__using__/1is the hook for theusemacro- Use macros for DSLs and compile-time code generation, not for simple functions
Cross-References
Section titled “Cross-References”- Basics and Pattern Matching: Pattern matching used in macro dispatch and guard clauses.
- Elixir Introduction: Language overview covering the functional paradigm macros extend.
Common Mistakes
Section titled “Common Mistakes”Using functions where macros are needed: Compile-time code generation (DSLs, defstruct, defprotocol) requires macros. Using regular functions for these tasks fails because they receive evaluated values, not AST.
Forgetting quote/unquote boundaries: Writing code outside quote blocks in a macro executes at compile time, not injecting into the caller. Always wrap generated code in quote do...end.
Breaking hygiene with var! unnecessarily: var! escapes the macro’s hygiene boundary, risking variable name collisions with the caller. Only use var! when you explicitly need to modify the caller’s scope.