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.
Docker users: always run mix format on the host, never inside the container. The Linux container converts CRLF to LF, corrupting every file in git diff. See TESTING.md for details.
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 — the leading value is not transformed
"hello"
|> String.upcase()
|> IO.puts()
# Right — assign first if the chain is complex, or write it directly
IO.puts(String.upcase("hello"))# 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}# 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 — 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))
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 |
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(fn ->
with {:ok, user} <- create_user(params),
{:ok, _} <- send_welcome_email(user) do
user
else
{:error, reason} -> Repo.rollback(reason)
end
end)# 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)
end@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 (1.6+)
- Designing Elixir Systems with OTP — James Edward Gray II