This document defines idiomatic Elixir patterns for any project running on the BEAM. These are not style preferences — they reflect how the runtime actually works. Writing Elixir that looks like Python or Ruby is not acceptable, even if it compiles and runs.
The BEAM is not a faster Python runtime. It is a fault-tolerant, concurrent, distributed virtual machine designed for systems that must run forever. Every pattern in this document exists because it maps to how the BEAM works:
- Processes are cheap — spawn them, let them crash, restart them
- Immutability is enforced — data does not change, it is transformed
- Pattern matching is the primary dispatch mechanism — not if/else chains
- Supervision is built into the runtime — use it, do not work around it
- "Let it crash" is a design principle, not laziness
If you are uncomfortable with any of these, read Programming Elixir before contributing.
Run mix format before every commit. No exceptions. CI enforces this.
A .formatter.exs is in the repository root. Do not modify it without discussion.
Pattern matching is the primary tool for control flow, destructuring, and dispatch. Use it everywhere.
# Wrong
def process(result) do
if elem(result, 0) == :ok do
value = elem(result, 1)
do_something(value)
end
end
# Right
def process({:ok, value}), do: do_something(value)
def process({:error, reason}), do: handle_error(reason)# Wrong
def handle_message(msg) do
if msg[:type] == "ping" do
:pong
else
:unknown
end
end
# Right
def handle_message(%{"type" => "ping"}), do: :pong
def handle_message(_msg), do: :unknowndef divide(a, b) when is_number(a) and is_number(b) and b != 0 do
{:ok, a / b}
end
def divide(_, 0), do: {:error, :division_by_zero}
def divide(_, _), do: {:error, :invalid_arguments}# Wrong — raises for expected conditions
def find_user!(id) do
Repo.get!(User, id)
end
# Right — returns a tagged tuple
def find_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end# Wrong — nested case pyramid
case authenticate(token) do
{:ok, user} ->
case authorize(user, :write) do
:ok ->
case save(data) do
{:ok, record} -> {:ok, record}
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
{:error, reason} -> {:error, reason}
end
# Right
with {:ok, user} <- authenticate(token),
:ok <- authorize(user, :write),
{:ok, record} <- save(data) do
{:ok, record}
end# Wrong — try/rescue for flow control is Python thinking
try do
record = Repo.get!(Model, id)
{:ok, record}
rescue
Ecto.NoResultsError -> {:error, :not_found}
end
# Right
case Repo.get(Model, id) do
nil -> {:error, :not_found}
record -> {:ok, record}
end# Wrong
try do
{:ok, String.to_integer(value)}
rescue
ArgumentError -> {:error, :invalid}
end
# Right
case Integer.parse(value) do
{n, ""} -> {:ok, n}
_ -> {:error, :invalid}
end# Wrong — hides bugs completely
try do
do_something()
rescue
_ -> nil
end
# Acceptable — at minimum log what happened
try do
do_something()
rescue
e ->
Logger.warning("do_something failed: #{Exception.message(e)}", error: e)
{:error, :unexpected}
end
# Best — let it crash, let the supervisor restart
do_something()Silent rescue _ makes the system appear healthy when it is not. Supervisors exist to handle crashes. Use them.
Bang functions (get!, fetch!, parse!) raise on failure. Use them only when failure means the program itself is wrong — misconfigured, missing required data at boot time. Never use them for expected runtime failures like "user not found" or "invalid input."
Atoms are never garbage collected. Each unique atom created from a runtime string is a permanent memory allocation.
# Wrong — memory leak, will eventually crash the VM
key = String.to_atom("provider_#{id}")
key = :"#{module_name}_handler"
# Right — use tuples as keys
key = {:provider, id}
key = {module_name, :handler}The only acceptable use of String.to_atom/1 is with a compile-time known, bounded set of values. If the value comes from user input, a database, or any external source, use a tuple or string key instead.
String.to_existing_atom/1 is safer but still requires the atom to have been created at compile time.
# Wrong — single-value pipe adds noise
result = value |> transform()
# Right
result = transform(value)# Wrong — unreadable wall of pipes
result = input |> String.trim() |> String.downcase() |> String.replace(" ", "_") |> String.slice(0, 50)
# Right
result =
input
|> String.trim()
|> String.downcase()
|> String.replace(" ", "_")
|> String.slice(0, 50)# Wrong
if not valid?(x), do: handle_invalid(x)
# Right
unless valid?(x), do: handle_invalid(x)Double negation is hard to reason about. unless with an else branch is explicitly discouraged by the Elixir style guide.
# Wrong
unless disabled? do
:active
else
:inactive
end
# Right
if disabled? do
:inactive
else
:active
end# Wrong — noise
result = case value do
x -> x
end
# Right
result = value# Wrong
def handle(params) do
if Map.has_key?(params, :user_id) do
if Map.has_key?(params, :action) do
if params.action in [:read, :write] do
execute(params)
else
{:error, :invalid_action}
end
else
{:error, :missing_action}
end
else
{:error, :missing_user_id}
end
end
# Right
def handle(%{user_id: _, action: action} = params) when action in [:read, :write] do
execute(params)
end
def handle(%{user_id: _, action: _}), do: {:error, :invalid_action}
def handle(%{action: _}), do: {:error, :missing_user_id}
def handle(_), do: {:error, :missing_action}list ++ [item] copies the entire list on every iteration. This is O(n²).
# Wrong — O(n²)
Enum.reduce(items, [], fn item, acc ->
acc ++ [transform(item)]
end)
# Right — prepend O(1), reverse once O(n)
items
|> Enum.reduce([], fn item, acc -> [transform(item) | acc] end)
|> Enum.reverse()
# Better — just use Enum.map
Enum.map(items, &transform/1)# Wrong — manual recursion for a standard operation
defp sum([]), do: 0
defp sum([h | t]), do: h + sum(t)
# Right
Enum.sum(items)# Wrong — loads everything into memory
File.read!("large_file.log")
|> String.split("\n")
|> Enum.filter(&String.contains?(&1, "ERROR"))
|> Enum.take(100)
# Right — lazy, processes line by line
File.stream!("large_file.log")
|> Stream.filter(&String.contains?(&1, "ERROR"))
|> Enum.take(100)# Wrong
"Hello " <> name <> ", you are " <> Integer.to_string(age) <> " years old."
# Right
"Hello #{name}, you are #{age} years old."# Regex
pattern = ~r/^\d{4}-\d{2}-\d{2}$/
# Multiline strings
query = """
SELECT *
FROM users
WHERE active = true
"""
# String lists
roles = ~w(admin editor viewer)# Wrong — map for a domain entity with known fields
user = %{name: "Alex", email: "alex@example.com", role: :admin}
# Right — struct enforces the shape
defmodule User do
defstruct [:name, :email, :role]
end
user = %User{name: "Alex", email: "alex@example.com", role: :admin}Fields that must always be present should be enforced at compile time, not validated at runtime.
# Wrong — missing required fields only fail at runtime, maybe
defmodule Connection do
defstruct [:host, :port, timeout: 5_000]
end
# Right — missing :host or :port raises at compile time
defmodule Connection do
@enforce_keys [:host, :port]
defstruct [:host, :port, timeout: 5_000]
end# Wrong — matches any map with a name key
def greet(%{name: name}), do: "Hello #{name}"
# Right — only matches User structs
def greet(%User{name: name}), do: "Hello #{name}"# Wrong
timeout = Map.get(config, :timeout)
timeout = if timeout == nil, do: 5000, else: timeout
# Right
timeout = Map.get(config, :timeout, 5000)# Wrong — crash is invisible, no restart, no monitoring
Task.start(fn -> do_background_work() end)
# Right — crash is reported, supervised
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn -> do_background_work() end)Add the supervisor to your application tree:
# application.ex
children = [
{Task.Supervisor, name: MyApp.TaskSupervisor},
# ...
]# Wrong — works, but sends a message through the mailbox unnecessarily;
# another message could arrive and be processed before :do_init
def init(args) do
send(self(), :do_init)
{:ok, initial_state(args)}
end
def handle_info(:do_init, state) do
{:noreply, do_heavy_init(state)}
end
# Right — handle_continue runs before any other message is processed
def init(args) do
{:ok, initial_state(args), {:continue, :init}}
end
def handle_continue(:init, state) do
{:noreply, do_heavy_init(state)}
endThis causes a deadlock. Extract the logic into a private function and call it directly.
# Wrong — deadlock
def handle_call(:do_thing, _from, state) do
result = GenServer.call(__MODULE__, :helper)
{:reply, result, state}
end
# Right
def handle_call(:do_thing, _from, state) do
result = compute_helper(state)
{:reply, result, state}
end
defp compute_helper(state), do: ...# Wrong — business logic buried in callback
def handle_call({:process, data}, _from, state) do
result =
data
|> validate()
|> enrich()
|> persist()
|> notify()
{:reply, result, %{state | last_processed: DateTime.utc_now()}}
end
# Right — callback delegates to pure functions
def handle_call({:process, data}, _from, state) do
result = process(data)
{:reply, result, update_state(state)}
end
defp process(data) do
data
|> validate()
|> enrich()
|> persist()
|> notify()
end
defp update_state(state), do: %{state | last_processed: DateTime.utc_now()}# Synchronous — caller waits for result
GenServer.call(pid, {:get, key})
# Asynchronous — caller does not wait
GenServer.cast(pid, {:update, key, value})GenServer state lives in the process heap. Large binaries, growing lists, or cached datasets should live in ETS, a database, or a dedicated cache process.
# In GenServer init
def init(state) do
schedule_tick()
{:ok, state}
end
def handle_info(:tick, state) do
do_periodic_work()
schedule_tick()
{:noreply, state}
end
defp schedule_tick do
Process.send_after(self(), :tick, :timer.seconds(30))
endProcess.put/2 and Process.get/2 are global mutable state scoped to a process. They are
invisible to callers, invisible to supervisors, and make code untestable. The only acceptable
uses are Logger metadata and library internals.
# Wrong — hidden state, impossible to test or inspect
Process.put(:current_user, user)
user = Process.get(:current_user)
# Right — pass state explicitly
def handle_request(conn, user) do
do_work(conn, user)
end| Strategy | Use when |
|---|---|
:one_for_one |
Children are independent — default choice |
:one_for_all |
Children are interdependent — restart all on any failure |
:rest_for_one |
Children have ordered dependencies — restart failed + all started after it |
# alias — resolves a module name locally, no code is imported
alias MyApp.Accounts.User
# Now %User{} works instead of %MyApp.Accounts.User{}
# import — brings functions into scope; avoid unless the benefit is clear
import Ecto.Query, only: [from: 2, where: 3]
# use — injects code via __using__/1 macro, significant side effect
# Only use when a behaviour or macro injection is explicitly required
use GenServer
use Phoenix.LiveViewPrefer alias over import. Use use only when the library requires it (GenServer, LiveView,
Ecto.Schema, etc.). Never use a module just to avoid typing the full name.
A module that does too many things should be split. Signs a module needs splitting:
- More than ~300 lines
- Functions that could be grouped into clearly distinct namespaces
- Mix of pure business logic and side effects (DB, HTTP, IO)
MyApp.Accounts # User, Session, Token operations
MyApp.Accounts.User # Schema only
MyApp.Content # Post, Comment, Tag operations
MyApp.Content.Post # Schema only
Context modules are the public API. Schemas are private data structures.
# Wrong — same utility function copy-pasted across 5 modules
defp blank?(nil), do: true
defp blank?(""), do: true
defp blank?(_), do: false
# Right — one module, used everywhere
defmodule MyApp.Utils do
def blank?(nil), do: true
def blank?(""), do: true
def blank?(_), do: false
endCallers before helpers. Public functions at the top of the module.
@spec find_user(integer()) :: {:ok, User.t()} | {:error, :not_found}
def find_user(id) do
defmodule MyApp.Accounts do
@moduledoc """
Manages user accounts, sessions, and authentication.
"""
If a function must be public (e.g., for use in tests or callbacks) but should not appear in documentation:
@doc false
def __callback__, do: :okChoose one timestamp type and use it across all schemas. :utc_datetime is recommended. Do not mix :naive_datetime and :utc_datetime.
Never write directly to the database without a changeset. Changesets provide validation, type casting, and an audit trail.
# Wrong
Repo.insert!(%User{name: name, email: email})
# Right
%User{}
|> User.changeset(%{name: name, email: email})
|> Repo.insert()Repo.transaction/1 with with works for simple cases. Ecto.Multi is the right tool when
you have multiple named steps, need to inspect which step failed, or want composable transaction
building.
# Acceptable for simple two-step cases
Repo.transaction(fn ->
with {:ok, user} <- create_user(params),
{:ok, _log} <- create_audit_log(user) do
user
else
{:error, reason} -> Repo.rollback(reason)
end
end)
# Right for complex multi-step operations — named steps, clear failure attribution
Ecto.Multi.new()
|> Ecto.Multi.insert(:user, User.changeset(%User{}, params))
|> Ecto.Multi.insert(:profile, fn %{user: user} ->
Profile.changeset(%Profile{}, %{user_id: user.id})
end)
|> Ecto.Multi.run(:notify, fn _repo, %{user: user} ->
Notifications.send_welcome(user)
end)
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
{:error, :notify, reason, _} -> {:error, reason}
end# Wrong — each access hits the database inside the loop
orders = Repo.all(Order)
Enum.each(orders, fn order -> IO.inspect(order.items) end)
# Right — single query with preload
orders = Repo.all(Order) |> Repo.preload(:items)
Enum.each(orders, fn order -> IO.inspect(order.items) end)If you access an association that has not been preloaded, Ecto raises Ecto.Association.NotLoaded.
Never rescue that — fix the preload.
# Acceptable for simple queries
Repo.get(User, id)
Repo.all(User)
# Right for complex queries
from(u in User,
where: u.active == true and u.role == ^role,
order_by: [desc: u.inserted_at],
limit: ^limit
)
|> Repo.all()(See Atoms section above — this is also a security issue, not just a memory issue.)
# Wrong — timing attack vulnerability
signature == computed_signature
# Right — constant-time comparison
Plug.Crypto.secure_compare(signature, computed_signature)# Wrong — XSS vulnerability
html = "<p>Hello #{user_input}</p>"
# Right — in Phoenix templates, assign to a variable and let HEEx escape it
# In controllers, use Phoenix.HTML.html_escape/1
safe_input = Phoenix.HTML.html_escape(user_input)# Wrong
signing_salt: "my_app_salt"
secret_key: "hardcoded_secret"
# Right
signing_salt: Application.fetch_env!(:my_app, :signing_salt)
secret_key: System.fetch_env!("SECRET_KEY")# Wrong — tests internal function
test "parse_response transforms the map correctly" do
assert MyModule.parse_response(%{...}) == %{...}
end
# Right — tests public contract
test "fetch_user returns {:ok, user} when user exists" do
user = insert(:user)
assert {:ok, ^user} = Accounts.fetch_user(user.id)
endTests should run concurrently unless they touch shared mutable state: database without the Ecto sandbox, named processes, ETS tables, or global config.
# Default for unit tests and context tests using the Ecto sandbox
use MyApp.DataCase, async: true
# Only false when sharing state that cannot be isolated
use MyApp.DataCase, async: false@tag :integration
test "creates a record in the database" do
@tag :unit
test "validates email format" doRun subsets with mix test --only unit or mix test --exclude integration.
# Wrong — brittle, couples tests to schema details
user = %User{id: 1, name: "Test", email: "test@test.com", role: :admin, ...}
# Right — factory handles defaults, tests specify only what matters
user = insert(:user, role: :admin)Every function that returns {:error, reason} should have at least one test that exercises that path.
This document covers Elixir/OTP code. It does not define conventions for:
- Python code — follow PEP 8
- JavaScript/TypeScript — follow the project's ESLint config
- SQL — use Ecto query syntax where possible; raw SQL in separate files with comments
- Shell scripts — follow Google Shell Style Guide
- Elixir Style Guide
- Credo — static analysis for Elixir style
- Dialyxir — typespec verification
- Programming Elixir — Dave Thomas
- Designing Elixir Systems with OTP — James Edward Gray II