Error Handling

Elixir gives you two different styles for handling things that can go wrong:

  • Tagged tuples like {:ok, value} and {:error, reason} for everyday, expected outcomes (a file might not exist, a database might reject a write, a network call might fail).

  • Exceptions (raise, try, rescue) for truly unexpected situations that should interrupt the normal flow.

The community prefers tagged tuples for almost everything. Reserve exceptions for bugs and "this should never happen" cases.

The {:ok, _} / {:error, _} Idiom

Most functions in the Elixir standard library return a two-element tuple. The first element is an atom that tells you what happened, the second element is the value or the reason:

iex> Integer.parse("42")
{42, ""}
iex> Integer.parse("abc")
:error

iex> Map.fetch(%{a: 1}, :a)
{:ok, 1}
iex> Map.fetch(%{a: 1}, :b)
:error

A function that needs to succeed or fail typically returns {:ok, value} or {:error, reason}. Here is a small example:

iex> defmodule Account do
...>   def withdraw(balance, amount) when amount <= balance do
...>     {:ok, balance - amount}
...>   end
...>
...>   def withdraw(_balance, _amount) do
...>     {:error, :insufficient_funds}
...>   end
...> end

iex> Account.withdraw(100, 30)
{:ok, 70}
iex> Account.withdraw(100, 200)
{:error, :insufficient_funds}

Branching on the Result

Once a function gives you a tagged tuple, you pattern match on the two shapes. A case is the most direct tool:

case Account.withdraw(100, 30) do
  {:ok, new_balance} ->
    IO.puts("New balance: #{new_balance}")
  {:error, reason} ->
    IO.puts("Could not withdraw: #{reason}")
end

For a chain of operations that all have to succeed, with is the more readable choice because it only mentions the error paths once.

Raising an Exception

Use raise/1 to stop the normal flow when something truly should not happen:

iex> raise "oh no"
** (RuntimeError) oh no

You can be more specific by naming an exception module:

iex> raise ArgumentError, "age must be an integer"
** (ArgumentError) age must be an integer
If a function can fail in a way the caller should handle, return {:error, reason}. Save raise for bugs and programmer mistakes.

try / rescue / after

When a call can raise an exception and you need to keep running, wrap it in a try:

iex> try do
...>   raise "boom"
...> rescue
...>   e in RuntimeError -> "rescued: #{e.message}" (1)
...> end
"rescued: boom"
1 e in RuntimeError binds e to the exception struct if it matches the RuntimeError type. You can list more than one type, separated by commas.

The optional after block always runs, whether the code raised or not. It is the place for cleanup work:

iex> try do
...>   raise "boom"
...> rescue
...>   _ -> "rescued"
...> after
...>   IO.puts("always runs")
...> end
always runs
"rescued"
Most Elixir code never uses try. If you are about to write one, ask yourself whether the function you are calling has a safer twin that returns {:error, _} instead of raising. For example, use File.read/1 (tagged tuple) instead of File.read!/1 (raises).

Custom Exceptions

You can define your own exception module with defexception:

iex> defmodule MyApp.NotFound do
...>   defexception message: "resource not found"
...> end

iex> raise MyApp.NotFound
** (MyApp.NotFound) resource not found

iex> raise MyApp.NotFound, message: "user 42 not found"
** (MyApp.NotFound) user 42 not found

Custom exceptions give you a type to match against in a rescue block, which is nicer than matching on the message string.

throw and catch

Elixir also has throw and catch, which let you bail out of a deep call stack without going through the exception machinery. You will almost never need them. If you see a library using throw it is usually inside a single function as an implementation detail, not as a way to model application errors.

Rules of Thumb

  • Expected failure: return {:ok, _} / {:error, _}.

  • Unexpected failure (a bug): raise.

  • Use try / rescue only when you cannot avoid a raising call.

  • If you see ! at the end of a function name (File.read!/1), that function raises on error. Its non-! twin returns a tagged tuple.

Agentic Coding Tip: "Let It Crash" vs the Agent’s try / rescue Reflex

An AI agent trained on Java, Python, or Ruby has a strong reflex: wrap anything that might fail in try / catch / rescue, log the error, return a default, move on. That is the opposite of idiomatic Elixir.

Elixir’s error-handling philosophy rests on two ideas:

  • Expected failures (missing user, invalid input, network timeout) are values — return {:error, reason} from the function and let the caller decide with a case.

  • Unexpected failures (a genuine bug) should crash the process. A supervisor restarts it in a clean state, and the bad input doesn’t corrupt state in the rest of the system. This is what "let it crash" means.

When Claude wraps every database call in try / rescue and returns nil on error, three things go wrong at once: the supervisor has nothing to act on (the process stayed alive), the nil propagates to places that expect a real value, and the operator never finds out there was a bug.

Rule to add to your project’s CLAUDE.md:

Don't wrap code in `try` / `rescue` by default. Elixir's
"let it crash" philosophy expects unexpected errors to
kill the process so a supervisor can restart it clean.
Use `try` / `rescue` only when:
  (a) you're calling into code that raises as its contract
      (File.read!/1, Ecto.Repo.one!/1) and a missing result
      is an expected application-level case;
  (b) you're at a shutdown/cleanup boundary that must run
      regardless of the inner outcome.
For expected failures, return `{:ok, _}` / `{:error, _}`
tuples and let the caller pattern-match. Never silently
rescue and return `nil` — that hides real bugs.