DEV Community

Cover image for Learning Elixir: Module Attributes
João Paulo Abreu
João Paulo Abreu

Posted on

Learning Elixir: Module Attributes

I like to think of module attributes as the sticky notes I stick on the drawers of my toolbox. Each drawer — each module — already has a label and a set of tools, but sometimes I want to attach a little extra information: a reminder of what the drawer is for, a note about a setting, or a scratch calculation done before I start the day. The sticky note is not a tool itself, but it tells me and everyone else something useful about what is inside. Module attributes work the same way. They hang off a module at compile time, carrying documentation, configuration, and values that are computed once and reused everywhere. In this article, I will explore the three roles attributes play in Elixir, the reserved attributes the language understands, how to describe functions with @spec, and how to create custom attributes that accumulate values across the module.

Note: The examples in this article use Elixir 1.20.1. While most operations should work across different versions, some functionality might vary.

Table of Contents

Introduction

In the previous article, I introduced module attributes by using them as constants. I defined @app_name, @max_attempts, and @retry_delays at the top of a module and read them inside functions. That worked, but it only scratched the surface. The official documentation describes module attributes as serving three purposes, and understanding all three changed how I see them:

  • As annotations — they attach metadata to a module or a function, like documentation and type information
  • As temporary storage — they hold values that are computed during compilation and discarded afterward
  • As compile-time constants — they bake fixed values into the compiled code

What I learned about module attributes:

  • They live at compile time — the value is substituted before the code ever runs
  • They use a simple syntax@name value sets them, @name reads them
  • Some are reserved — Elixir understands a fixed set, like @moduledoc and @spec
  • They are not variables — I cannot reassign them at runtime
  • They can be registered — with Module.register_attribute/3 for custom behavior
  • They power the ecosystem — frameworks like ExUnit use them behind the scenes

The sticky note analogy carried me through this article: some notes label the drawer, some are temporary scratch paper, and some are the final values I want frozen in place.

Understanding Module Attributes

Setting and Reading Attributes

The syntax is symmetrical. Inside a module, I set an attribute by writing its name with @ followed by a value, and I read it the same way but without the value:

defmodule Greeter do
  @greeting "Hello"

  def greet(name) do
    "#{@greeting}, #{name}!"
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Greeter.greet("world")
"Hello, world!"
Enter fullscreen mode Exit fullscreen mode

The @greeting is substituted directly into the function body. What runs at runtime is not "read the attribute @greeting" but the literal string "Hello".

Attributes Are Compile Time, Functions Are Runtime

I found it useful to draw a firm line between these two worlds. When the compiler processes a module, it walks the code top to bottom. Every time it sees @name value, it stores that value in a compile-time table. When it later sees @name inside a function, it replaces it with whatever value is currently in the table.

Once the module is compiled, the table is gone. Only the functions remain, with the attribute values baked into them. This is why attributes are "temporary storage" in a real sense — they exist only while the compiler is working.

This mental model explained several surprises:

  • I cannot read an attribute that was never set — the compiler warns about it
  • I cannot use an attribute before the line that sets it
  • The order of definitions inside the module matters

One Attribute, Many Definitions

There is a subtlety I had to learn the hard way. If I set the same attribute twice, the second value wins — unless the attribute is registered to accumulate, which I will cover in the custom attributes section:

defmodule Overwrite do
  @limit 10
  @limit 20

  def limit, do: @limit
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Overwrite.limit()
20
Enter fullscreen mode Exit fullscreen mode

Without accumulation, defining @limit twice simply overwrites the first value. The same rule applies to reserved attributes like @moduledoc — the compiler warns if I set it twice with documentation text.

Attributes as Compile Time Constants

The Basic Pattern

The most common use I have found is defining constants. Instead of scattering magic numbers and strings through functions, I define them once at the top of the module:

defmodule AppConfig do
  @app_name "my_app"
  @max_attempts 5
  @retry_delays [100, 200, 500]

  def app_name, do: @app_name
  def max_attempts, do: @max_attempts
  def retry_delays, do: @retry_delays
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> AppConfig.app_name()
"my_app"

iex> AppConfig.max_attempts()
5

iex> AppConfig.retry_delays()
[100, 200, 500]
Enter fullscreen mode Exit fullscreen mode

The values are resolved at compile time, so they are efficient — the compiled functions contain the final values directly.

Computing a Constant at Compile Time

Because attributes are evaluated during compilation, I can compute one value from another right there in the module:

defmodule Limits do
  @max_limit 100
  @half_max div(@max_limit, 2)

  def max_limit, do: @max_limit
  def half_max, do: @half_max
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Limits.max_limit()
100

iex> Limits.half_max()
50
Enter fullscreen mode Exit fullscreen mode

Here @half_max runs div/2 once, at compile time, and the result 50 is what gets baked in. I never pay that cost at runtime.

Attributes vs Functions as Constants

One thing the official documentation pointed out that changed my defaults: for a plain constant, a private function works just as well as an attribute, and it avoids the compile-time snapshot behavior:

defmodule PreferFunctions do
  def hours_in_a_day, do: 24
end
Enter fullscreen mode Exit fullscreen mode

I now reach for a private function when the value is simple and I just want a named constant. I reach for an attribute when I need to do real work at compile time and inject the result into the code — like the @half_max example, or an attribute used inside a guard.

The Reserved Attributes List

Elixir keeps a map of every reserved attribute. I found it reassuring to check it directly instead of memorizing a long list:

iex> Enum.count(Module.reserved_attributes())
29

iex> Map.has_key?(Module.reserved_attributes(), :spec)
true

iex> Map.has_key?(Module.reserved_attributes(), :moduledoc)
true
Enter fullscreen mode Exit fullscreen mode

Every attribute in that map has a defined meaning to the compiler. Using any of them for a different purpose would be confusing, so custom attributes use other names.

Attributes in Patterns and Guards

There is one place where constants as attributes shine: inside patterns and guards. Guards only accept a limited set of expressions, and a module attribute is one of the allowed ones. This is a handy alternative to repeating literal values.

In Guards

defmodule GuardExample do
  @time_periods [:am, :pm]

  def describe(time, period) when period in @time_periods do
    "#{time} #{period}"
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> GuardExample.describe("09:00", :am)
"09:00 am"

iex> GuardExample.describe("21:00", :pm)
"21:00 pm"
Enter fullscreen mode Exit fullscreen mode

If the periods ever change, I update one attribute instead of every guard clause.

In Patterns

The value of an attribute can also be pinned into a function head, matching exactly that value:

defmodule PatternExample do
  @default_timezone "Etc/UTC"

  def shift(@default_timezone), do: :default
  def shift(other), do: {:custom, other}
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> PatternExample.shift("Etc/UTC")
:default

iex> PatternExample.shift("America/Sao_Paulo")
{:custom, "America/Sao_Paulo"}
Enter fullscreen mode Exit fullscreen mode

The first clause matches only the exact string stored in @default_timezone, and any other value falls through to the second clause. This uses the pattern matching ideas from the earlier article about pattern matching, with a compile-time constant standing in for the literal.

Attributes as Temporary Storage

The documentation describes attributes as temporary storage because they exist to be computed at compile time and then discarded — except where the compiler substituted them. A classic scenario is pre-computing an expensive or inconvenient value once and injecting it into functions.

Precomputing a Value

Let me parse a URL once at compile time and reuse the parsed result:

defmodule MyApp.Status do
  @service URI.parse("https://example.com")

  def host, do: @service.host
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> MyApp.Status.host()
"example.com"
Enter fullscreen mode Exit fullscreen mode

What gets baked into host/0 is not a call to URI.parse/1. It is the resulting struct, frozen into the compiled code. The parsing work happens exactly once, during compilation.

The Snapshot Behavior

I learned a practical detail from the official docs: every time I read an attribute inside a function, Elixir takes a snapshot of its current value at that point in the module. If I read the same attribute in many different functions, the compiler has to compile each snapshot, which adds up at compile time.

The documentation suggests a cleaner approach: read the attribute once, into a private function, and call that function everywhere else:

defmodule SnapshotExample do
  @default_retries 3

  def run, do: attempt(default_retries())

  defp default_retries, do: @default_retries
  defp attempt(remaining) when remaining > 0, do: {:retrying, remaining}
  defp attempt(_remaining), do: :give_up
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> SnapshotExample.run()
{:retrying, 3}
Enter fullscreen mode Exit fullscreen mode

Here default_retries/0 is the single place that reads @default_retries; every other function calls it instead. In a small module the difference is negligible, but it explains a pattern I kept seeing in real Elixir code: constants exposed through small functions rather than read directly all over the module.

The Value, Not the Call

It took me a moment to understand that the compiler substitutes the return value of the attribute expression, not the expression itself. This is what makes attributes different from a function call that recomputes at runtime. The parse, the division, the string interpolation — all of it runs once at compile time, and only the result remains.

Documentation Attributes

Recapping @moduledoc and @doc

In the previous article I used @moduledoc and @doc to document modules and functions. They are the most widely used reserved attributes, and Elixir treats documentation as a first-class feature. The pattern is to place a heredoc right above what it describes:

defmodule Calculator do
  @moduledoc """
  Provides basic arithmetic operations.
  """

  @doc """
  Adds two numbers.

  ## Examples

      iex> Calculator.add(1, 2)
      3
  """
  def add(a, b), do: a + b
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Calculator.add(1, 2)
3
Enter fullscreen mode Exit fullscreen mode

Hiding From the Docs

Both attributes accept false, which tells documentation tools like ExDoc to skip the module or function. This is useful for internal helpers that should not clutter the public docs:

defmodule Internal do
  @moduledoc false

  def helper, do: :internal_only
end
Enter fullscreen mode Exit fullscreen mode

I found this handy for modules that exist only to support other modules and would only add noise to the documentation site.

Documentation Metadata

Since Elixir 1.7.0, @doc and @moduledoc also accept a keyword list of metadata. A common use is the :since key, which records in which version an entity appeared:

defmodule DocMeta do
  @moduledoc "A module."

  @doc "Adds two numbers."
  @doc since: "1.20.0"
  def add(a, b), do: a + b
end
Enter fullscreen mode Exit fullscreen mode

The metadata is merged into the compiled documentation and picked up by tools. I treat this as a nice detail rather than something I use every day.

One thing worth knowing: documentation is only accessible from compiled modules. When I define a module by pasting it straight into an IEx session, there is no .beam file with docs chunks yet, so tools cannot read the documentation back. That is expected behavior, not a bug.

@spec: Describing Function Contracts

Why Write a Spec

Elixir is dynamically typed, so @spec never changes how the code runs. It exists for people and for analysis tools. Writing a spec for a public function forces me to think about what it accepts and returns, and it gives tools like Dialyzer a contract to check my code against.

A spec looks like this:

defmodule StringHelpers do
  @spec long_word?(String.t()) :: boolean()
  def long_word?(word) when is_binary(word) do
    String.length(word) > 8
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> StringHelpers.long_word?("supercalifragilistic")
true

iex> StringHelpers.long_word?("ok")
false
Enter fullscreen mode Exit fullscreen mode

The spec reads almost like English: long_word?/1 takes a String.t() and returns a boolean(). The :: separates the arguments from the return type.

Common Types to Know

I started with a small vocabulary of built-in types and added more as I went:

  • integer(), float(), number()
  • boolean() — shorthand for true | false
  • atom(), binary(), String.t()
  • list(type) — a list whose elements are type
  • non_neg_integer() — integers 0, 1, 2, ...
  • any() — anything at all

The union operator | lets me say "one of these":

@spec fetch(keyword) :: {:ok, term} | :error
Enter fullscreen mode Exit fullscreen mode

That spec says fetch/1 either returns a two-element tuple {:ok, term} or the atom :error. This matches the tagged tuple convention from the error handling article.

A Practical Spec

Let me combine types, a union, and the tagged tuple pattern into something realistic:

defmodule UserLookup do
  @spec find(non_neg_integer()) :: {:ok, String.t()} | {:error, :not_found}
  def find(1), do: {:ok, "alice"}
  def find(_id), do: {:error, :not_found}
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> UserLookup.find(1)
{:ok, "alice"}

iex> UserLookup.find(99)
{:error, :not_found}
Enter fullscreen mode Exit fullscreen mode

The spec documents the contract: give it a non-negative integer, get either a success tuple with a name or a failure tuple with a reason. Anyone reading the module understands the possible outcomes before reading a single clause.

Naming Arguments

I can also name the arguments inside a spec. This is especially helpful when several arguments share the same type:

defmodule Report do
  @spec build_report(title :: String.t(), rows :: non_neg_integer()) :: String.t()
  def build_report(title, rows), do: "#{title}: #{rows} rows"
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Report.build_report("Sales", 42)
"Sales: 42 rows"
Enter fullscreen mode Exit fullscreen mode

The named arguments are purely for documentation — they help me tell title from rows at a glance.

The string() Pitfall

One trap the docs warn about: the type string() does not mean an Elixir string. It refers to Erlang strings, which are charlists in Elixir. Using it produces a warning. The right types for Elixir strings are String.t() or binary(). I use String.t() because it signals a UTF-8 encoded binary to anyone reading the docs.

Defining Custom Types

For a single function, built-in types are enough. When the same shape appears across several functions, I define a named type with @type:

defmodule Order do
  @type order_id :: non_neg_integer()
  @type status :: :pending | :paid | :shipped

  @spec update_status(order_id(), status()) :: {:ok, order_id()} | {:error, :unknown_order}
  def update_status(_id, _status), do: {:error, :unknown_order}
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Order.update_status(42, :paid)
{:error, :unknown_order}
Enter fullscreen mode Exit fullscreen mode

Now order_id() and status() appear in every relevant spec, and the union of possible statuses lives in one place. If I add a status, I update the type instead of hunting through the module.

Elixir offers three flavors:

  • @type — a public type, visible in the docs
  • @typep — a private type, only for use inside the module
  • @opaque — a public type whose inner structure stays hidden

I use @typep for helper types that are implementation details, the same way I use defp for helper functions.

The @vsn Attribute

The @vsn attribute sets the module version. It accepts any value, and the compiler stores it as the module's version metadata:

defmodule Versioned do
  @vsn "1.0"
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Versioned.module_info(:attributes)
[vsn: ["1.0"]]
Enter fullscreen mode Exit fullscreen mode

Every module already has a vsn — Elixir generates one automatically when the module is compiled. Setting @vsn explicitly replaces that default with my own value. This is the kind of attribute I rarely touch in application code, but it is good to know it exists when a project needs to track module versions.

The @derive Attribute

The @derive attribute is tied to structs, which I explored in the article about structs. When I define a struct, Elixir automatically implements the Inspect protocol for it. @derive lets me ask for additional protocol implementations — or to customize how an existing one behaves.

Deriving a Protocol

A common use is hiding fields from Inspect output. By default, inspecting a struct shows every field:

defmodule Point do
  defstruct [:x, :y]
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> %Point{x: 1, y: 2}
%Point{x: 1, y: 2}
Enter fullscreen mode Exit fullscreen mode

With @derive {Inspect, only: [:x]}, I tell Elixir to derive an Inspect implementation that shows only the x field. I use a fresh struct name here so defining the same struct twice does not trigger a "redefining module" warning in the IEx session:

defmodule SecretPoint do
  @derive {Inspect, only: [:x]}
  defstruct [:x, :y]
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> %SecretPoint{x: 1, y: 2}
#SecretPoint<x: 1, ...>
Enter fullscreen mode Exit fullscreen mode

The output now hides y. This is useful when a struct carries sensitive or noisy internal data that I do not want splashed across logs and IEx output. It is one of those attributes that makes no sense until you hit the problem it solves — and then you reach for it everywhere.

The Rest of the Reserved Attributes

I explored the full list in Module.reserved_attributes(), and a few more are worth knowing even if I do not use them yet:

  • @behaviour — declares that a module implements a behaviour (note the British spelling). Behaviours get their own article later, but this attribute is how a module announces which contract it follows.
  • @impl — marks a function as the implementation of a callback. It works together with @behaviour and helps the compiler warn when I implement the wrong function.
  • @compile — configures compiler options, like inlining a function with @compile {:inline, some_fun: 1}.
  • @deprecated — marks a function as deprecated, so Mix warns when it is called. Library authors use it to guide users toward newer APIs.
  • @type, @typep, @opaque — define types, which I covered above.
  • @derive — derives protocol implementations for structs, also covered above.
  • @enforce_keys — ensures certain keys are always set when building a struct.
  • @on_load — runs a function whenever the module is loaded, often used to load native code.

I did not need most of these in everyday application code. The important thing was recognizing that they are all the same mechanism — annotations the compiler and the ecosystem read — so when I see an unfamiliar @something, I know exactly what kind of thing it is.

Custom Attributes and Accumulation

So far, all the attributes I have used are reserved by Elixir. But I can also create my own attributes with any valid name, and I can control how they behave with Module.register_attribute/3.

A Simple Custom Attribute

A custom attribute without registration is just a compile-time value:

defmodule Project do
  @team ["Alice", "Bob"]

  def team, do: @team
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Project.team()
["Alice", "Bob"]
Enter fullscreen mode Exit fullscreen mode

There is nothing special about the name @team — I invented it. Custom attributes like this are how libraries build annotation systems on top of Elixir.

Registering an Attribute

Module.register_attribute/3 is where custom attributes become powerful. Inside the module definition, I register an attribute and describe how it should behave:

defmodule Collected do
  Module.register_attribute(__MODULE__, :tasks, accumulate: true)

  @tasks :compile
  @tasks :link
  @tasks :run

  def tasks, do: @tasks
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Collected.tasks()
[:run, :link, :compile]
Enter fullscreen mode Exit fullscreen mode

Without accumulate: true, each @tasks definition would overwrite the previous one — only the last value would survive. With accumulation, every definition is collected, and each new value is added to the top of the list. This is exactly how ExUnit's @tag works: each tag accumulates onto a list that the test runner reads later.

The Options

Module.register_attribute/3 accepts two options:

  • accumulate: true — repeated definitions collect into a list instead of overwriting
  • persist: true — the attribute is kept in the module's metadata for interop with Erlang tools

Both default to false, and once an attribute is set to accumulate, that behavior cannot be reverted.

Custom Attributes as Compile Time Storage

This is where the "temporary storage" purpose shines. A library can use a registered attribute to collect information across the whole module — every function, every declaration — and then process it all at the end of compilation. ExUnit collects tags. Other libraries collect metadata to generate code. I will not go deeper into that here, because doing it for real involves macros, but understanding the mechanism helps me appreciate what is happening under the hood.

Practical Guidelines

I use attributes for compile-time work — when a value needs to be computed once and injected, or used inside a guard or pattern, an attribute is the right tool.

I prefer functions for plain constants — a simple fixed value works fine as a private function, and it avoids the compile-time snapshot cost of reading attributes in many places.

I write @spec for public functions — describing the contract helps me design the function and lets tools catch mistakes. I start with a small set of built-in types and grow my vocabulary gradually.

I keep docs next to the code@moduledoc and @doc belong directly above what they describe, and writing them forces me to summarize the purpose in my own words.

I use @type for repeated shapes — when the same type appears in several specs, I name it once with @type instead of repeating the shape.

I register custom attributes deliberately — I use Module.register_attribute/3 with accumulate: true whenever I want to collect values across the module, and I document why the attribute exists.

I remember attributes are not runtime state — if I need a value that changes while the program runs, module attributes are the wrong tool. That is a problem for the process-based state we will meet later in the series.

Conclusion

Module attributes turned out to be much more than the constants I first used them as. The moment I understood they are compile-time annotations and storage, a lot of Elixir code I had been reading made sense — the @spec on every function, the @moduledoc at the top, the @tag in tests.

Some things I learned:

  • Attributes have three roles — annotations, temporary storage, and compile-time constants
  • They are compile-time only — values are substituted during compilation, never changed at runtime
  • @spec describes contracts — documenting inputs and outputs helps both people and analysis tools
  • @type keeps types in one place — naming a shape once makes specs consistent
  • @vsn and @derive fill specific gaps — versioning modules and customizing struct behavior
  • register_attribute enables accumulation — custom attributes can collect values across the whole module

The sticky note picture held up well: some notes label the drawer, some are scratch paper used during compilation, and some hold the final settings I want frozen in place. Now that I understand the full picture, module attributes feel less like a syntax quirk and more like the language's way of letting code describe itself.

Further Reading

Next Steps

With module attributes under my belt, the natural next step is learning how modules refer to each other. In the previous article I mentioned alias in passing, and it is the first of a family of directives that control how names from other modules become available in mine.

In the next article, we will explore:

  • Using alias to shorten module names without hiding where they come from
  • Bringing functions into scope with import and knowing when to be careful with it
  • require for modules that expose macros
  • The use shortcut and how it differs from require
  • How these directives shape the namespace of a module

These directives work hand in hand with everything we just covered — module attributes and module organization only get more useful once modules can cleanly reference one another.

Top comments (0)