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)
# ** (FunctionClauseError) no function clause matching in String.upcase/2
# MatchError - pattern matching failed
{:ok, value} = {:error, :not_found}
# ** (MatchError) no match of right hand side value: {:error, :not_found}
To see the messages without crashing the IEx session, I wrapped them in small functions:
defmodule ExceptionExamples do
def runtime_message do
try do
raise "Something went wrong"
rescue
e in RuntimeError -> e.message
end
end
def arithmetic_message do
try do
divide_by_zero()
rescue
e in ArithmeticError -> "Math error: #{e.message}"
end
end
# Written this way so the compiler does not evaluate the division ahead of time
defp divide_by_zero do
zero = 0
1 / zero
end
end
Testing in IEx:
iex> ExceptionExamples.runtime_message()
"Something went wrong"
iex> ExceptionExamples.arithmetic_message()
"Math error: bad argument in arithmetic expression"
How Exceptions Work
I learned that exceptions in Elixir are not like exceptions in many other languages. They are used for truly exceptional circumstances, not for regular control flow. The philosophy is:
- Let it crash — if a process encounters an error, let it fail and be restarted by a supervisor
- Explicit over implicit — prefer returning error tuples over raising exceptions
- Exceptions are a last resort — use them when you have no other choice
defmodule ExceptionDemo do
# This raises an exception - we would not normally write code like this
def will_crash do
raise "This is an error"
end
# Better: return an error tuple
def will_return_error do
{:error, "This is an error"}
end
# Wrapping the crash in try/rescue
def crash_safely do
try do
will_crash()
rescue
RuntimeError -> "Caught it!"
end
end
end
Testing in IEx:
iex> ExceptionDemo.will_return_error()
{:error, "This is an error"}
iex> ExceptionDemo.crash_safely()
"Caught it!"
Rescuing Exceptions with try/rescue
Basic Rescue Pattern
The try/rescue block catches exceptions that occur within the try block:
defmodule Rescuer do
def safe_divide(a, b) do
try do
result = a / b
{:ok, result}
rescue
ArithmeticError -> {:error, :division_by_zero}
end
end
def parse_date(string) do
try do
{:ok, Date.from_iso8601!(string)}
rescue
ArgumentError -> {:error, :invalid_date_format}
end
end
end
Testing in IEx:
iex> Rescuer.safe_divide(10, 2)
{:ok, 5.0}
iex> Rescuer.safe_divide(10, 0)
{:error, :division_by_zero}
iex> Rescuer.parse_date("2024-03-15")
{:ok, ~D[2024-03-15]}
iex> Rescuer.parse_date("not-a-date")
{:error, :invalid_date_format}
Rescuing Multiple Exception Types
You can rescue different exception types with different handlers:
defmodule MultiRescuer do
def process_file(path) do
try do
contents = File.read!(path)
data = String.to_integer(String.trim(contents))
{:ok, data}
rescue
File.Error ->
{:error, :file_not_found}
ArgumentError ->
{:error, :invalid_number}
e in RuntimeError ->
{:error, {:runtime_error, e.message}}
end
end
end
Testing in IEx:
iex> MultiRescuer.process_file("nonexistent.json")
{:error, :file_not_found}
Accessing the Exception
When you rescue an exception, you can bind it to a variable and inspect its fields:
defmodule ExceptionInspector do
def inspect_error(operation) do
try do
operation.()
rescue
e in ArgumentError ->
{:argument_error, e.message}
e in RuntimeError ->
{:runtime_error, e.message}
e ->
{:unknown_error, Exception.message(e)}
end
end
end
Testing in IEx:
iex> ExceptionInspector.inspect_error(fn -> raise ArgumentError, "bad arg" end)
{:argument_error, "bad arg"}
iex> ExceptionInspector.inspect_error(fn -> raise "Something failed" end)
{:runtime_error, "Something failed"}
Stack Traces
You can also access the stack trace when rescuing:
defmodule StackTraceDemo do
def boom do
raise "Oops"
end
def log_error(operation) do
try do
operation.()
rescue
e ->
[{module, function, arity, _location} | _rest] = __STACKTRACE__
IO.puts("Exception: #{Exception.message(e)}")
IO.puts("Raised in: #{inspect(module)}.#{function}/#{arity}")
{:error, :logged}
end
end
end
Testing in IEx:
iex> StackTraceDemo.log_error(&StackTraceDemo.boom/0)
Exception: Oops
Raised in: StackTraceDemo.boom/0
{:error, :logged}
Important: __STACKTRACE__ is only available inside rescue, catch, and else blocks. It returns the stack trace for the current exception.
Defining and Raising Custom Exceptions
Creating Custom Exceptions
Elixir allows you to define your own exception types using defexception. This is useful when you want to signal specific error conditions in your application:
defmodule PaymentError do
defexception [:message, :code, :transaction_id]
@impl true
def exception(attrs) do
message = attrs[:message] || "Payment failed"
code = attrs[:code] || :unknown
transaction_id = attrs[:transaction_id]
%PaymentError{message: message, code: code, transaction_id: transaction_id}
end
end
Testing in IEx:
iex> PaymentError.exception(message: "Insufficient funds", code: :insufficient_funds)
%PaymentError{message: "Insufficient funds", code: :insufficient_funds, transaction_id: nil}
iex> raise PaymentError, message: "Card declined", code: :card_declined
** (PaymentError) Card declined
Simpler Custom Exceptions
For many cases, a simpler definition is sufficient:
defmodule ValidationError do
defexception [:field, :message]
end
Testing in IEx:
iex> raise ValidationError, field: :email, message: "is required"
** (ValidationError) is required
Using Custom Exceptions in Practice
defmodule PaymentProcessor do
def charge(card_number, amount) do
with {:ok, validated_card} <- validate_card(card_number),
{:ok, _sufficient} <- check_balance(validated_card, amount) do
do_charge(validated_card, amount)
end
end
defp validate_card(card_number) do
if String.length(card_number) == 16 do
{:ok, card_number}
else
raise ValidationError,
field: :card_number,
message: "must be 16 digits, got #{String.length(card_number)}"
end
end
defp check_balance(_card, amount) when amount > 0 do
{:ok, true}
end
defp check_balance(_card, amount) do
raise PaymentError,
message: "Amount must be positive, got #{amount}",
code: :invalid_amount
end
defp do_charge(card, amount) do
# Simulate successful charge with a deterministic fake ID
transaction_id = "txn_#{amount}_#{String.slice(card, -4, 4)}"
{:ok, %{transaction_id: transaction_id, card: card, amount: amount}}
end
# Wraps charge/2 and converts exceptions into tagged tuples
def charge_safely(card_number, amount) do
try do
charge(card_number, amount)
rescue
e in ValidationError -> {:validation_failed, e.field, e.message}
e in PaymentError -> {:payment_failed, e.code, e.message}
end
end
end
Testing in IEx:
iex> PaymentProcessor.charge_safely("1234567890123456", 100)
{:ok, %{amount: 100, card: "1234567890123456", transaction_id: "txn_100_3456"}}
iex> PaymentProcessor.charge_safely("1234", 100)
{:validation_failed, :card_number, "must be 16 digits, got 4"}
iex> PaymentProcessor.charge_safely("1234567890123456", -50)
{:payment_failed, :invalid_amount, "Amount must be positive, got -50"}
Note: In real code, I would probably return {:error, reason} for validation failures instead of raising. I show this example to demonstrate how custom exceptions work when you need them.
Using catch for Exits and Throws
The Difference Between rescue and catch
I learned that rescue and catch handle different things:
-
rescuecatches exceptions — errors that represent something going wrong -
catchcatches throws and exits — different mechanisms for non-local control flow
Catching Throws
throw is a way to escape from a deep computation quickly. It is not an error — it is a control flow mechanism:
defmodule ThrowDemo do
def find_first_match(items, predicate) do
try do
Enum.each(items, fn item ->
if predicate.(item) do
throw({:found, item})
end
end)
:not_found
catch
{:found, item} -> {:ok, item}
end
end
end
Testing in IEx:
iex> ThrowDemo.find_first_match([1, 2, 3, 4, 5], fn x -> x > 3 end)
{:ok, 4}
iex> ThrowDemo.find_first_match([1, 2, 3], fn x -> x > 10 end)
:not_found
Important: Using throw for control flow is generally discouraged in Elixir. I find that in most cases, Enum.find/2 or restructuring the code with explicit returns is cleaner. I show this for completeness, but I try to avoid throw in my own code.
Catching Exits
The catch clause also handles exits raised with exit/1 inside the try block. An exit is another form of non-local control flow — the code asks the current process to terminate with a reason:
defmodule ExitDemo do
def abort_with_reason(action) do
try do
case action do
:stop -> exit(:stopped_early)
:fail -> exit({:failed, :bad_input})
:ok -> :completed
end
catch
:exit, reason -> {:caught_exit, reason}
end
end
end
Testing in IEx:
iex> ExitDemo.abort_with_reason(:ok)
:completed
iex> ExitDemo.abort_with_reason(:stop)
{:caught_exit, :stopped_early}
iex> ExitDemo.abort_with_reason(:fail)
{:caught_exit, {:failed, :bad_input}}
Important: catch only catches exits raised inside the try block itself. It does not catch exit signals sent by other processes. When a linked process dies, a process that is not trapping exits simply dies too — the catch clause never runs. I learned this the hard way while testing these examples in IEx.
To react when another process dies, the pattern is different: trap exits so signals arrive as regular {:EXIT, pid, reason} messages:
defmodule ExitSignalDemo do
def watch_linked_process do
Process.flag(:trap_exit, true)
spawn_link(fn -> exit(:abnormal) end)
receive do
{:EXIT, _pid, reason} -> {:process_exited, reason}
after
1_000 -> :no_exit_received
end
end
end
Testing in IEx:
iex> ExitSignalDemo.watch_linked_process()
{:process_exited, :abnormal}
Note: Catching exits and trapping exit signals are both rare in normal application code. The "let it crash" philosophy means we usually let supervisors handle failing processes instead of catching exits ourselves. I show these patterns because they appear in library code, and understanding them helped me read process-related code with more confidence.
Catch Syntax
The catch clause has a specific syntax:
try do
# code that might throw or exit
rescue
# catches exceptions
catch
:throw, value ->
# catches throw(value)
{:thrown, value}
:exit, reason ->
# catches exit(reason)
{:exited, reason}
kind, value ->
# catches any kind (throw or exit)
{:caught, kind, value}
end
The after Clause for Cleanup
Guaranteed Cleanup
The after clause is one of my favorite parts of try. It guarantees that code runs whether or not an exception occurred. I think of it as a "finally" block, but with Elixir's elegant syntax:
defmodule FileHandler do
def process_file(path, processor) do
file = File.open!(path, [:read])
try do
contents = IO.read(file, :eof)
processor.(contents)
after
File.close(file)
end
end
end
Testing in IEx:
iex> File.write!("/tmp/demo.txt", "Hello from Elixir")
:ok
iex> FileHandler.process_file("/tmp/demo.txt", &String.trim/1)
"Hello from Elixir"
Note: In modern Elixir, File.open/2 with a function callback already handles cleanup, so this pattern is less common. I show it to illustrate how after works.
After with Return Values
The after clause does not affect the return value of the try block:
defmodule AfterDemo do
def with_cleanup do
try do
:success
after
IO.puts("Cleanup runs!")
end
end
def with_error_cleanup do
try do
raise "Error!"
after
IO.puts("Cleanup still runs!")
end
end
# Wrapping the error in try/rescue
def with_rescued_error do
try do
with_error_cleanup()
rescue
_ -> "Rescued after cleanup"
end
end
end
Testing in IEx:
iex> AfterDemo.with_cleanup()
Cleanup runs!
:success
iex> AfterDemo.with_rescued_error()
Cleanup still runs!
"Rescued after cleanup"
Common Cleanup Patterns
defmodule ResourceManager do
def with_temp_file(contents, callback) do
path = "/tmp/elixir_temp_#{:rand.uniform(100000)}.txt"
try do
File.write!(path, contents)
callback.(path)
after
File.rm(path)
end
end
end
Testing in IEx:
iex> ResourceManager.with_temp_file("temporary contents", fn path -> File.read!(path) end)
"temporary contents"
Combining Rescue, Catch, and After
Complete try Block
You can combine all the clauses in a single try block:
defmodule CompleteHandler do
def handle_operation(operation) do
try do
result = operation.()
{:ok, result}
rescue
e in ArithmeticError ->
{:arithmetic_error, e.message}
e in ArgumentError ->
{:argument_error, e.message}
catch
:throw, value ->
{:thrown, value}
:exit, reason ->
{:exited, reason}
after
IO.puts("Operation completed (success or failure)")
end
end
end
Testing in IEx:
iex> CompleteHandler.handle_operation(fn -> 42 end)
Operation completed (success or failure)
{:ok, 42}
iex> CompleteHandler.handle_operation(fn -> throw(:early_exit) end)
Operation completed (success or failure)
{:thrown, :early_exit}
iex> CompleteHandler.handle_operation(fn -> 1 / 0 end)
Operation completed (success or failure)
{:arithmetic_error, "bad argument in arithmetic expression"}
Order of Clauses
The order matters: rescue is checked first, then catch, and after always runs last — whether the operation succeeded or failed.
Using else with try
A try block can also have an else clause that pattern matches on the result when no exception occurs, similar to the else in with. I almost never use it — I find it clearer to handle the success value directly in the try body or with case afterwards — so I mention it here only so you recognize it when reading other people's code.
Practical Guidelines
What I Learned About Best Practices
I prefer tagged tuples for expected errors — if I can predict that something might fail, I return {:error, reason}. This makes the error path explicit in the function's return value.
I use try/rescue when calling external code — libraries, parsing functions, or OS operations might raise exceptions that I cannot prevent. Wrapping them in try/rescue and converting to tagged tuples creates a consistent interface.
I avoid rescue for control flow — using exceptions for regular logic makes code harder to follow. I reserve them for truly exceptional circumstances.
I use after for resource cleanup — whether opening files, starting transactions, or acquiring locks, after ensures cleanup happens.
I let processes crash when appropriate — in OTP applications, supervisors handle process failures. I do not wrap every call in try/rescue because that defeats the fault-tolerance model.
defmodule Guidelines do
# Good: explicit error handling
def divide_explicit(a, b) when b != 0 do
{:ok, div(a, b)}
end
def divide_explicit(_a, 0) do
{:error, :division_by_zero}
end
# Good: wrapping parsing that may raise
def parse_integer_safe(string) do
try do
{:ok, String.to_integer(string)}
rescue
ArgumentError -> {:error, :not_an_integer}
end
end
# Good: cleanup with after
def with_resource(resource, callback) do
try do
callback.(resource)
after
release(resource)
end
end
defp release(_resource), do: :ok
# Avoid: using exceptions for control flow
# def find_user_bad(id) do
# try do
# user = Repo.get!(User, id)
# {:ok, user}
# rescue
# Ecto.NoResultsError -> {:error, :not_found}
# end
# end
# Better: use the non-bang version
def find_user_good(id) do
case repo().get.(:user, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
defp repo do
# Mock for demonstration
%{get: fn _schema, id ->
if id == 1, do: %{id: 1, name: "Alice"}, else: nil
end}
end
end
Testing in IEx:
iex> Guidelines.divide_explicit(10, 2)
{:ok, 5}
iex> Guidelines.divide_explicit(10, 0)
{:error, :division_by_zero}
iex> Guidelines.find_user_good(1)
{:ok, %{id: 1, name: "Alice"}}
iex> Guidelines.find_user_good(99)
{:error, :not_found}
Converting Exceptions to Tagged Tuples
I find this pattern very useful — a wrapper function that converts exceptions into the {:ok, _} / {:error, _} pattern:
defmodule SafeWrapper do
def wrap_exception(fun) do
try do
{:ok, fun.()}
rescue
e -> {:error, Exception.message(e)}
end
end
def wrap_with_type(fun) do
try do
{:ok, fun.()}
rescue
e in ArgumentError -> {:error, {:argument_error, e.message}}
e in RuntimeError -> {:error, {:runtime_error, e.message}}
e -> {:error, {:unknown_error, Exception.message(e)}}
end
end
end
Testing in IEx:
iex> SafeWrapper.wrap_exception(fn -> String.to_integer("42") end)
{:ok, 42}
iex> SafeWrapper.wrap_exception(fn -> Date.from_iso8601!("not-a-date") end)
{:error, "cannot parse \"not-a-date\" as date, reason: :invalid_format"}
iex> SafeWrapper.wrap_with_type(fn -> raise ArgumentError, "bad" end)
{:error, {:argument_error, "bad"}}
Conclusion
Learning about try, rescue, catch, and after helped me understand how Elixir handles the unexpected. While the {:ok, value} / {:error, reason} pattern covers most day-to-day error handling, these constructs provide a safety net for situations where exceptions are unavoidable.
Some things I learned:
-
Tagged tuples are my default — I reach for
{:ok, _}and{:error, _}first because they make error paths explicit - Try/rescue wraps the unpredictable — I use it mainly when calling external libraries or system operations that might raise
-
Custom exceptions add clarity — defining
defexceptionhelps communicate specific error conditions in domain terms -
Catch handles throws and exits — I rarely use
throwfor control flow, but understandingcatchhelps me read library code -
After guarantees cleanup — the
afterclause ensures resources are released, connections closed, and state cleaned up - Let it crash still applies — in OTP applications, supervisors often handle failures better than manual rescue blocks
The key insight for me was that these tools are complementary, not competing. Tagged tuples handle expected problems. Try/rescue handles the unexpected. Using both wisely makes code both explicit and resilient.
Further Reading
- Elixir Official Documentation — try, catch, and rescue
- Elixir Official Documentation — Exceptions
- Elixir School — Error Handling
- Programming Elixir by Dave Thomas
Next Steps
With error handling patterns in place, the next step is learning how to organize code into reusable, named units. Creating Modules in Elixir is the foundation of every Elixir application, from small scripts to large distributed systems.
In the next article, we will explore:
- Defining modules with
defmoduleand understanding their structure - Creating public and private functions with
defanddefp - Using module attributes for metadata and constants
- How modules map to files and directories in a project
- Building a small module from scratch to tie the concepts together
Modules are where everything comes together — functions, error handling, and data structures — into cohesive, maintainable units of code.
Top comments (0)