I like to think of error handling in Elixir as having two different toolkits for two different kinds of problems. Tagged tuples — {:ok, value} and {:error, reason} — are like a polite conversation where both sides agree on what could go wrong ahead of time. But sometimes the unexpected happens: a process crashes, a library raises an exception, or you need to abort a deep computation with a throw. That is where try, catch, rescue, and after come in. They are like a safety net under a trapeze artist — you do not plan to fall, but you are glad the net is there when something goes wrong. In this article, I will explore when and how to use these constructs, and what I learned about keeping error handling intentional and readable.
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
- When to Use Try/Rescue vs Tagged Tuples
- Understanding Exceptions in Elixir
- Rescuing Exceptions with try/rescue
- Defining and Raising Custom Exceptions
- Using catch for Exits and Throws
- The after Clause for Cleanup
- Combining Rescue, Catch, and After
- Practical Guidelines
- Conclusion
- Further Reading
- Next Steps
Introduction
In our previous article about error handling basics, I explored the {:ok, value} and {:error, reason} pattern. That approach works wonderfully for predictable failures: a user not found, invalid input, a missing configuration key. But not all failures are predictable. Sometimes a library function raises an exception, an arithmetic operation divides by zero, or a remote call crashes the calling process. For those situations, Elixir provides try, rescue, catch, and after.
What I learned about these constructs:
-
Rescue catches raised exceptions — things like
RuntimeError,ArgumentError, or custom exceptions - Catch handles throws and exits — different mechanisms for non-local control flow
- After runs cleanup code — guaranteed to execute whether or not an error occurred
- They complement tagged tuples — they do not replace them, but handle a different category of problems
- Elixir discourages overuse — the philosophy is "let it crash" in many cases, so these tools are used sparingly
I found that understanding when not to use these constructs is just as important as knowing how they work.
When to Use Try/Rescue vs Tagged Tuples
The Core Distinction
I like to think of the difference this way: tagged tuples are for expected problems, while try/rescue is for unexpected problems that you cannot avoid.
defmodule Conversions do
# Expected problem: user provides invalid input
# Solution: return {:error, reason}
def divide(a, b) when is_number(a) and is_number(b) and b != 0 do
{:ok, a / b}
end
def divide(_a, 0), do: {:error, :division_by_zero}
def divide(_a, _b), do: {:error, :invalid_input}
# Unexpected problem: library raises an exception
# Solution: use try/rescue
def safe_parse_integer(string) do
try do
{:ok, String.to_integer(string)}
rescue
ArgumentError -> {:error, :not_an_integer}
end
end
end
Testing in IEx:
iex> Conversions.divide(10, 2)
{:ok, 5.0}
iex> Conversions.divide(10, 0)
{:error, :division_by_zero}
iex> Conversions.safe_parse_integer("42")
{:ok, 42}
iex> Conversions.safe_parse_integer("not_a_number")
{:error, :not_an_integer}
When I Prefer Tagged Tuples
defmodule FileReader do
# Better: explicit return values
def read_config(path) do
case File.read(path) do
{:ok, contents} -> parse_config(contents)
{:error, reason} -> {:error, {:file_read_failed, reason}}
end
end
defp parse_config(contents) do
# Parsing logic here
{:ok, contents}
end
end
When I Use Try/Rescue
defmodule SafeCalculator do
# Necessary: division raises an exception when divisor is zero
def safe_div(a, b) do
try do
{:ok, a / b}
rescue
ArithmeticError -> {:error, :division_by_zero}
end
end
# Another common case: parsing that may raise
def parse_positive_integer(string) do
try do
value = String.to_integer(string)
if value > 0, do: {:ok, value}, else: {:error, :not_positive}
rescue
ArgumentError -> {:error, :not_an_integer}
end
end
end
Testing in IEx:
iex> SafeCalculator.safe_div(10, 2)
{:ok, 5.0}
iex> SafeCalculator.safe_div(10, 0)
{:error, :division_by_zero}
iex> SafeCalculator.parse_positive_integer("42")
{:ok, 42}
iex> SafeCalculator.parse_positive_integer("abc")
{:error, :not_an_integer}
Understanding Exceptions in Elixir
Built-in Exception Types
Elixir has several built-in exception types that you might encounter:
# RuntimeError - general purpose exception
raise "Something went wrong"
# ** (RuntimeError) Something went wrong
# ArgumentError - wrong argument type or value
raise ArgumentError, message: "expected a positive integer"
# ** (ArgumentError) expected a positive integer
# ArithmeticError - math operations that fail
1 / 0
# ** (ArithmeticError) bad argument in arithmetic expression
# FunctionClauseError - no matching function clause
String.upcase(123