November 25, 2025

Macros in Elixir are unlike macros you’ve seen in any other programming language. They’re flexible, powerful and simple! I’m summarizing all my learnings about Elixir macros over the past month in this dense article.

Intro

Macros in Elixir are tools that work with the Elixir AST(Abstract Syntax Tree). For those of you who are unfamiliar with the AST term, understand that it is a representation of the compiled code that is used in generating instructions to the VM at a later stage.

Elixir’s AST

Quoting Elixir std. lib. documentation:

Any Elixir code can be represented using Elixir data structures. The building block of Elixir macros is a tuple with three elements

AST in Elixir is represented by a 3 element Elixir Tuple.

{:div, [context: Elixir, imports: [{2, Kernel}]], [4, 2]}

The above is the AST generated by Elixir when the Kernel.div/2 function is invoked.

  • First element is always an Elixir atom or an Elixir Tuple in the same 3 element form.
  • The second element contains metadata
  • The third element contains the arguments for the function call.

More on this in the following sections.

defmacro/2

A macro in Elixir can be defined using the defmacro/2 macro. That’s right! defmacro/2 is also an Elixir macro. In fact, many of the standard statements in Elixir such as def, defp, if are all macros. Even the @ symbol prefixed for each module attribute is also a macro. The core of Elixir is simple and much of the language is an extension implemented using macros.

defmacro/2 receives an AST as an input and must return an AST for valid usage.

Note that a macro definition must be housed in an Elixir module.

Here is an example from the Elixir Kernel documentation.

defmodule MyLogic do
  defmacro unless(expr, opts) do
    quote do
      if !unquote(expr), unquote(opts)
    end
  end
end

require MyLogic

MyLogic.unless false do
  IO.puts("It works")
end

unless/2 is a macro that receives an AST and returns an AST. quote and unquote help us in encapsulating and expanding AST nodes respectively. More on that in the next section.

There’s also a defmacrop/2 that allows you to define private macros that can be used within the module it was defined in.

quote/2

quote/2 is part of Elixir’s Kernel.SpecialForms module. It returns the AST of any Elixir expression.

Example:

iex(4)> ast_rep = quote do
                div(4,2)
              end 
iex(5)> ast_rep
{:div, [context: Elixir, imports: [{2, Kernel}]], [4, 20]}

unquote/1

This construct can be used only during compile-time, which is when Macros are evaluated.

unquote/ converts the AST representation back into it’s original Elixir expression. It’s important to note that unquote/1 can be used only inside a quote/2 block, the reason for this is that Elixir AST tuples are perfectly valid Elixir expressions but they are most likely not the intended substitues in the expressions evaluated by the quote/2 block.

From the above listed example, if you were to check the AST of the following code block which does not unquote ast_rep:

quote do
    sum(1, ast_rep)
end

## Evaluates to
{:sum, [], [1, {:ast_rep, [], Elixir}]}

This is no the intented substitution. You were intending to substitute div(4,2) and not it’s AST tuple. This is where unquote/1 is used, to convert an AST representation into the Elixir expression it was generated from. The intended (and correct) usage would be:

quote do 
    sum(1, unquote(ast_rep))
end

## Evaluates to
{:sum, [], [1, {:div, [context: Elixir, imports: [{2, Kernel}]], [4, 2]}]}

Note that ast_rep has to be a valid AST defined in the same context for the above unquote example to work successfully. A good practice is to pass all variables as arguments to defmacro/2.

:bind_quoted

If an AST is unquoted multiple times within the context of defmacro/2, it would be evaluated multiple times during runtime. :bind_quoted option of quote/2 lets you avoid that.

defmacro squared(expr) do
    quote do
        unquote(expr) * unquote(expr)
    end
end

IO.puts("Got #{squared(fn -> 
        IO.puts("Returning 5")
        5
    end)}")

# Returning 5
# Returning 5
# Got 25

You can see that the function was evaluated twice. This is probably not the intented result, if the function were manipulating an external resource, it would’ve performed the operation twice. Using :bind_quoted avoids multiple evaluations.

defmacro squared(expr) do
    quote bind_quoted: [result_var: expr] do
        result_var * result_var
    end
end

IO.puts("Got #{squared(fn -> 
        IO.puts("Returning 5")
        5
    end)}")

# Returning 5
# Got 25

Macro Hygiene

Any variables defined within the context of Macros are not leaked into the user context.

For example, the variable result_var from the above example cannot be used within the user context where squared/1 was invoked. This is Macro hygiene.

Macro hygiene can be violated explicitly by using the var! macro. Using var!/1 macro allows you to utilize and override variables from the user context.

var!/2 macro allows you to pass an Elixir module as the second argument to limit the scope of resolution and modification of the variable passed as the first arg, to the module defined in second arg.

Utilities to work with Macros

Macro.to_string/1

Converts the given expression AST to string. Invoking this on the above defined ast_rep would yield the following result:

iex(6)> ast_rep
{:div, [context: Elixir, imports: [{2, Kernel}]], [4, 2]}

iex(7)> Macro.to_string ast_rep
"div(4, 2)"

Macro.escape/2

This is used to escape an Elixir expression which is not an AST so it can be inserted into an AST. For example, if you were passing an Elixir map as an argument to a macro, you would escape it so it can be evaluated at runtime as a map.

map = %{"Hello" => "world"}

## This wouldn't work since map is not an AST
quote do
  Map.get(unquote(map), "Hello")
end

## This works
quote do
  Map.get(Macro.escape(map), "Hello")
end

Honorable mentions

unquote_splicing

Similar to unquote/1 but takes a List as an input and unquotes all the values in the list expanding its arguments.

Macro.prewalk/2

Performs a depth-first, pre-order traversal of quoted expressions. This is quite useful if you’re trying to optimize the code generated by your macros.

Macro.postwalk/2

Performs a depth-first, post-order traversal of the quoted expressions.

Implementing @idoc

The Iris library provides an @idoc attribute that allows developers to write documentation for private functions and macros. By contrast, using the @doc attribute on private functions isn’t supported by the ExDoc library (the de-facto documentation library for Elixir).

This Pull Request contains the implementation of @idoc macro. Let’s break it down.

Broadly outlined, the steps to achieve this are as follows:

  1. Define an idoc attribute in the user context to store documentation.
  2. Ensure that @idoc is used only before private functions or macros.
  3. Store the documentation against the function name in the defined idoc attribute.
  4. Expose a public function that returns the value of idoc attribute.

Step 1

Elixir provides a special __using__/1 macro that is invoked in the user context whenever a use statement is seen in a module.

defmodule Test do
  use IrisDoc
end

The __using__/1 defined in the IrisDoc module is invoked when Test module is compiled.

defmodule IrisDoc do
  defmacro __using__(_) do
    quote do
      # Single value user attribute
      Module.register_attribute(__MODULE__, :idoc, accumulate: false, persist: true)

      @before_compile {unquote(__MODULE__), :before_compile}
      @on_definition {unquote(__MODULE__), :on_definition}
    end
  end
end

Here we are registering an attribute with the name :idoc within the user context. Note that __MODULE__ here refers to the user module Test and not IrisDoc. To refer to IrisDoc, we must use unquote(__MODULE__).

Also, we’re registering the compile callbacks on_definition and before_compile to be invoked. The functions/macros reside in IrisDoc.

Step 2

Elixir provides an on_definition/6 compile callback that is invoked upon the definition of every function or macro.

We are leveraing this callback to see if an attribute is already defined by the time this callback is invoked, and to check if this callback was invoked on defp or defmacrop. If not, a CompileError is thrown.

Step 3

Notice the following line in __using__/1.

Module.register_attribute(__MODULE__, :__idocs__, accumulate: true)

If the option :accumulate is set to false, it means each subequent definition of the same attribute will override the value set by a previous definition. We need this behaviour in @idoc to ensure that it contains the documentation of the most recent function definition.

In the case of __idocs__ the option :accumulate is set to true. We are defining this special attribute to store all docs. We’re updating this in the on_definition/6 hook.

Step 4

We’re using the before_compile callback to verify there are no orphan @idoc attributes defined in the module and just before wrapping up, we inject a final piece of code.

defmacro before_compile(env) do
    ## ensure there are no orphan attributes.
    ....

    ## Inject this function into the user context.
    quote do
      def __idocs__(), do: @__idocs__
    end
end

We’re injecting __idocs__/1 into the user module, the Test module from the above example. It returns the value of @__idocs__. The shape of this attribute is a List of tuples that are added to this attribute in the on_definition/6 callback.

Note the following:

  1. before_compile must be a macro. Elixir allows this to be a function but it has to be a macro for the code injection to work as expected.
  2. before_compile is executed at the end of the compilation stage of the module and before the compilation finishes for the entire project. This is the reason the @__idocs__ attribute contains the values of all functions in the module. If this callback were to be executed before the compilation of the module begins, then the attribute would be empty, but that is not the case.

References

And that’s a wrap! I hope you learned something from this article that helps you appreciate the beauty of Elixir lang.

It’s almost magic.

~rahultumpala