Error Handling in Elixir
How Elixir handles failure -- {:ok, _}/{:error, _} tuples, try/rescue, and the with construct for chaining fallible steps.
3 min read
Elixir's approach to error handling is shaped directly by the "let it crash" philosophy: most errors are handled as ordinary return values, and exceptions are reserved for the genuinely unexpected.
The {:ok, _} / {:error, _} convention
The dominant pattern throughout Elixir and its ecosystem is returning a tagged tuple instead of raising:
defmodule Divider do
def divide(_a, 0), do: {:error, "cannot divide by zero"}
def divide(a, b), do: {:ok, a / b}
end
case Divider.divide(10, 2) do
{:ok, result} -> IO.puts("Result: #{result}")
{:error, reason} -> IO.puts("Error: #{reason}")
endThis makes failure an explicit, visible part of a function's contract — anyone calling divide/2 can see from its return values alone that it might fail, and the compiler-friendly pattern match forces you to at least consider both branches.
The bang (!) convention
Many Elixir libraries provide two versions of a function: a safe one returning {:ok, _}/{:error, _}, and a "bang" version ending in ! that raises an exception on failure and returns the raw value on success:
File.read("config.json")
# => {:ok, "..."} or {:error, :enoent}
File.read!("config.json")
# => "..." or raises File.ErrorUse the safe version when failure is expected and worth handling gracefully; use the ! version when failure would mean something is seriously wrong and the program genuinely can't continue — letting it crash (and, in a supervised system, restart) is the more honest response.
try/rescue for genuine exceptions
For the smaller set of cases involving true exceptions:
try do
1 / 0
rescue
ArithmeticError -> IO.puts("Can't divide by zero!")
endtry/rescue exists in Elixir, but it's used far less often than in exception-heavy languages, precisely because the tagged-tuple convention handles most "expected" failure cases without needing exceptions at all.
with for chaining fallible steps
Real-world logic often needs several steps to each succeed in turn, with the whole chain failing if any one does. Nesting case for this gets unwieldy fast — with solves it directly:
def create_account(params) do
with {:ok, email} <- validate_email(params["email"]),
{:ok, password} <- validate_password(params["password"]),
{:ok, user} <- save_user(email, password) do
{:ok, user}
else
{:error, reason} -> {:error, reason}
end
endEach <- clause pattern-matches like a normal match — but the moment one fails to match, with immediately stops and returns that non-matching value instead of raising a MatchError, falling through to the else block if one is present. This reads as "do these steps in order, bail out cleanly the moment one fails," which is exactly the shape most real validation and multi-step logic takes.
Custom exceptions
For genuinely exceptional situations specific to your domain, you can define your own:
defmodule InsufficientFundsError do
defexception message: "not enough funds to complete this transaction"
end
raise InsufficientFundsErrorChoosing the right tool
A practical rule: use {:ok, _}/{:error, _} tuples for anything a caller should reasonably expect and handle (a missing file, invalid input, a failed lookup); reserve raised exceptions for programmer errors and truly unexpected conditions; and reach for with the moment you have more than two chained fallible steps. Followed consistently, this keeps failure handling explicit and readable, rather than scattering try/rescue blocks through code that would read more clearly as a straightforward chain of pattern matches.
Test what you just learned
4 quick questions. Get all of them right to unlock the next lesson.
You can take the quiz without an account — logging in just lets your result count toward your progress.