Error Handling Patterns in Elixir: A Comprehensive Guide
Error handling in Elixir follows a fundamentally different philosophy compared to many other programming languages. Rather than trying to catch and handle every possible exception at every level, Elixir embraces the "let it crash" philosophy combined with robust supervision trees. Understanding these patterns is essential for building resilient, maintainable, and fault-tolerant applications on the BEAM virtual machine.
What Makes Elixir Error Handling Unique
Elixir runs on the BEAM (Bogdan/Björn's Erlang Abstract Machine), which was designed for telecom systems requiring extreme uptime. This heritage shaped a distinctive approach: instead of defensive programming with try/catch blocks scattered throughout code, Elixir encourages developers to write happy-path code and let processes fail fast. Supervisors then restore the system to a known good state. This doesn't mean you ignore errors entirely—it means you handle them at the right level of abstraction.
There are two distinct categories to understand: expected errors (like invalid user input, network timeouts, or missing database records) and unexpected errors (like logic bugs, configuration mistakes, or resource exhaustion). Elixir provides different tools for each.
Pattern Matching: The First Line of Defense
Elixir's pattern matching is the most common and idiomatic way to handle expected errors. Functions often return tagged tuples like {:ok, result} or {:error, reason}, and pattern matching lets you elegantly handle both outcomes.
# Typical result-oriented function
defmodule FileHandler do
def read_config(path) do
case File.read(path) do
{:ok, content} ->
case Jason.decode(content) do
{:ok, config} -> {:ok, config}
{:error, reason} -> {:error, {:parse_error, reason}}
end
{:error, reason} ->
{:error, {:file_error, reason}}
end
end
end
# Usage with pattern matching
case FileHandler.read_config("config.json") do
{:ok, config} ->
IO.puts("Database host: #{config["db_host"]}")
{:error, {:file_error, :enoent}} ->
IO.puts("Config file not found, using defaults")
{:error, {:parse_error, reason}} ->
IO.puts("Invalid JSON: #{inspect(reason)}")
end
This pattern makes error states explicit in the type signature (via the tagged tuples) and forces callers to handle both success and failure cases. The compiler doesn't enforce this, but convention and code review do.
The With Statement: Composing Happy-Path Operations
When you need to sequence multiple operations that can fail, the with statement provides a clean way to chain them without nested case blocks. It reads like a linear narrative of the success path while implicitly handling errors.
defmodule OrderProcessor do
def process_order(customer_id, product_id) do
with {:ok, customer} <- validate_customer(customer_id),
{:ok, product} <- check_inventory(product_id),
{:ok, payment} <- charge_customer(customer, product.price),
{:ok, shipment} <- create_shipment(product, customer.address) do
{:ok, %{order: product, tracking: shipment.tracking_id}}
else
{:error, :customer_not_found} ->
{:error, "Customer account does not exist"}
{:error, :out_of_stock} ->
{:error, "Product is currently unavailable"}
{:error, :insufficient_funds} ->
{:error, "Payment method was declined"}
{:error, reason} ->
Logger.error("Unexpected order failure: #{inspect(reason)}")
{:error, "Unable to process order at this time"}
end
end
defp validate_customer(id) do
# Returns {:ok, customer} | {:error, :customer_not_found}
end
defp check_inventory(product_id) do
# Returns {:ok, product} | {:error, :out_of_stock}
end
defp charge_customer(customer, amount) do
# Returns {:ok, payment} | {:error, :insufficient_funds}
end
defp create_shipment(product, address) do
# Returns {:ok, shipment} | {:error, reason}
end
end
The with statement binds on the right arrow (<-) and short-circuits to the else block on the first non-matching clause. This keeps error handling separate from the main logic flow. You can also omit the else block entirely, in which case non-matching values simply propagate to the caller.
Try, Rescue, and Catch: Handling Runtime Exceptions
For unexpected runtime errors—things like arithmetic errors, function clause mismatches, or external resource failures—Elixir provides try/rescue. This should be used sparingly and typically at the boundaries of your application.
defmodule ExternalAPIClient do
def fetch_user_data(user_id) do
try do
response = HTTPoison.get!("https://api.example.com/users/#{user_id}")
Jason.decode!(response.body)
rescue
e in HTTPoison.Error ->
{:error, {:http_error, e.reason}}
e in Jason.DecodeError ->
{:error, {:invalid_response, e.message}}
e in RuntimeError ->
Logger.error("Unexpected error fetching user: #{Exception.message(e)}")
{:error, :unexpected_api_error}
end
end
end
Elixir also distinguishes between rescue (for exceptions) and catch (for throws and exits). Throws are used with throw/1 for non-local returns (rare in idiomatic Elixir), while exits come from process links and the exit/1 function. In practice, you'll use rescue most often.
defmodule ExitExample do
def safe_operation do
try do
potentially_dangerous_work()
catch
:exit, reason ->
{:error, {:process_exit, reason}}
:throw, value ->
{:caught_throw, value}
end
end
def potentially_dangerous_work do
# Some work that might throw or cause an exit signal
end
end
You can also use the after clause to guarantee cleanup regardless of outcome, similar to finally in other languages.
defmodule DatabaseConnection do
def with_transaction(db_pool, fun) do
conn = checkout_connection(db_pool)
try do
begin_transaction(conn)
result = fun.(conn)
commit_transaction(conn)
{:ok, result}
rescue
e ->
rollback_transaction(conn)
{:error, {:transaction_failed, e}}
after
# Always runs, even if an exception occurred
checkin_connection(db_pool, conn)
end
end
end
Custom Error Types and Structured Errors
For complex applications, simple atoms like :not_found become insufficient. Elixir allows you to define custom exception modules and structured error maps for richer error reporting.
defmodule MyApp.InvalidInputError do
defexception [:field, :value, :reason]
@impl true
def message(%{field: field, value: value, reason: reason}) do
"Invalid input for #{field}: got #{inspect(value)}. #{reason}"
end
end
defmodule UserValidator do
def validate_age(age) when is_integer(age) and age >= 0 and age < 150 do
{:ok, age}
end
def validate_age(value) do
raise MyApp.InvalidInputError,
field: :age,
value: value,
reason: "Age must be an integer between 0 and 150"
end
def validate_email(email) do
if String.contains?(email, "@") do
{:ok, String.downcase(email)}
else
{:error, %{
field: :email,
value: email,
reason: "Email must contain @ symbol",
code: :invalid_format
}}
end
end
end
Using structured error maps (rather than just atoms) gives consumers more context for logging, debugging, or user-facing error messages. It also allows pattern matching on specific fields.
The Supervisor Pattern: Let It Crash Gracefully
The most powerful error handling pattern in Elixir isn't in your application code at all—it's in the supervision tree. When a process crashes, its supervisor restarts it (and possibly sibling processes) according to a predefined strategy.
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
# If this crashes, restart it with the same state
{MyApp.Repo, name: MyApp.Repo, pool_size: 10},
# Temporary worker: if it crashes, don't restart
{Task, fn -> initialize_cache() end, restart: :temporary},
# If the cache crashes, restart the web server too
{MyApp.Cache, name: MyApp.Cache},
{MyAppWeb.Endpoint, name: MyAppWeb.Endpoint}
]
opts = [
strategy: :one_for_one, # Restart only the crashed child
name: MyApp.Supervisor,
max_restarts: 5,
max_seconds: 10
]
Supervisor.start_link(children, opts)
end
end
Supervision strategies include :one_for_one (restart only the failed process), :one_for_all (restart all children if one fails), and :rest_for_one (restart the failed process and all that started after it). This allows you to write code that assumes success and fails fast when assumptions are violated, knowing the supervisor will restore a clean state.
Error Logging and Observability
Error handling is incomplete without proper logging. Elixir's Logger module provides levels and metadata to help you understand what went wrong.
defmodule PaymentProcessor do
require Logger
def process_payment(payment_data) do
Logger.info("Processing payment", payment_id: payment_data.id)
with {:ok, validated} <- validate_payment(payment_data),
{:ok, processed} <- execute_payment(validated) do
Logger.info("Payment succeeded", payment_id: payment_data.id, amount: processed.amount)
{:ok, processed}
else
{:error, :insufficient_funds} ->
Logger.warn("Payment failed: insufficient funds",
payment_id: payment_data.id,
customer_id: payment_data.customer_id)
{:error, :insufficient_funds}
{:error, reason} ->
Logger.error("Payment processing failed",
payment_id: payment_data.id,
error: inspect(reason))
{:error, :processing_error}
end
end
end
In production, you'll want to integrate with tools like Telemetry, Sentry, or Loggly for aggregated error tracking. The key is to log enough context to debug issues without exposing sensitive data.
Best Practices for Elixir Error Handling
- Use tagged tuples for expected errors: Return
{:ok, value}or{:error, reason}rather than raising exceptions for situations callers should anticipate. - Raise exceptions for truly exceptional cases: Use
raise/1when the error indicates a bug or configuration problem that shouldn't be handled inline. - Keep rescue blocks at the edges: Place
try/rescueat module boundaries (HTTP controllers, message handlers, task entry points) rather than deep in business logic. - Use
withfor composing operations: It reads clearly and separates error handling from the success path. Avoid deeply nestedcasestatements. - Design supervisors thoughtfully: Choose restart strategies based on process dependencies. Use
:temporaryfor one-off tasks, and consider backpressure for resource-intensive processes. - Log errors with context: Include metadata like user IDs, request IDs, and timestamps. Structure logs for easy querying in production.
- Never silently swallow errors: Even when you handle an error gracefully, log it. Silent failures make debugging impossible.
- Test error paths: Write tests for error cases just as thoroughly as success cases. Use ExUnit's
assert_raisefor exceptions and pattern matching for tagged tuples. - Consider error monads for complex pipelines: Libraries like
dry-monadsor custom result types can help whenwithbecomes unwieldy.
Testing Error Scenarios
Thorough testing of error paths ensures your handlers work correctly in production. ExUnit provides excellent tools for this.
defmodule PaymentProcessorTest do
use ExUnit.Case
test "returns insufficient funds error when balance too low" do
# Setup: mock or prepare low-balance account
payment_data = %{customer_id: "user123", amount: 1000}
result = PaymentProcessor.process_payment(payment_data)
assert {:error, :insufficient_funds} = result
end
test "raises on completely invalid payment data" do
assert_raise MyApp.InvalidInputError, ~r/Invalid input for amount/, fn ->
PaymentProcessor.process_payment(%{customer_id: "user123", amount: "free"})
end
end
test "handles external API timeout gracefully" do
# Using a mock or bypass to simulate timeout
bypass = Bypass.open()
Bypass.expect(bypass, "POST", "/charge", fn conn ->
Plug.Conn.send_resp(conn, 500, "timeout")
end)
result = PaymentProcessor.process_payment(%{customer_id: "user123", amount: 50})
assert {:error, :processing_error} = result
end
end
Common Anti-Patterns to Avoid
- Rescuing everything indiscriminately:
rescue _ -> ...hides bugs. Be specific about which exceptions you expect. - Using exceptions for control flow: Don't raise exceptions for validation errors or "not found" cases. That's what tagged tuples are for.
- Deeply nested case statements: Three levels of
casenesting is a strong signal to refactor withwithor separate functions. - Ignoring the supervision tree: If you find yourself wrapping every function call in try/rescue, reconsider your supervision strategy.
- Mixing
throwwith regular control flow:throwis extremely rare in idiomatic Elixir and confuses readers. Reserve it for specific non-local return scenarios in algorithms.
Real-World Example: A Resilient Web Controller
Here's a complete example combining multiple patterns in a Phoenix controller, demonstrating how error handling works in practice at the application boundary.
defmodule MyAppWeb.OrderController do
use MyAppWeb, :controller
alias MyApp.{Orders, Payments, Notifications}
def create(conn, params) do
with {:ok, order_params} <- validate_order_params(params),
{:ok, order} <- Orders.create_order(order_params),
{:ok, payment} <- Payments.charge(order.total, order.customer_id),
{:ok, _notification} <- Notifications.send_receipt(order, payment) do
conn
|> put_status(:created)
|> render("order.json", %{order: order, payment: payment})
else
{:error, :validation_failed} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "Invalid order parameters"})
{:error, :product_unavailable} ->
conn
|> put_status(:conflict)
|> json(%{error: "One or more products are no longer available"})
{:error, {:payment_error, reason}} ->
Logger.error("Payment failed for order", reason: inspect(reason))
conn
|> put_status(:payment_required)
|> json(%{error: "Payment could not be processed"})
{:error, unexpected} ->
Logger.error("Unexpected order creation failure: #{inspect(unexpected)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "An unexpected error occurred"})
end
end
defp validate_order_params(params) do
# Validation logic returning {:ok, cleaned_params} | {:error, :validation_failed}
end
end
This controller cleanly separates the success path (in the with block) from error responses. Each error case maps to an appropriate HTTP status code, and unexpected errors are logged with full context. If a truly catastrophic error occurs (like a database connection loss), the process crashes and the supervisor restarts it—the client receives a 500, but the system self-heals.
Conclusion
Error handling in Elixir is a layered discipline. At the innermost level, pattern matching on tagged tuples provides explicit, predictable error paths for expected failures. The with statement composes these operations elegantly. For runtime exceptions, try/rescue acts as a safety net at module boundaries. Above all of this, the supervision tree ensures that when something does crash, the system recovers automatically. By combining these patterns thoughtfully—matching on results for domain errors, raising for true bugs, and letting supervisors handle the rest—you build applications that are both resilient in production and maintainable in development. The key insight is that not all errors are the same: treat expected failures as data, unexpected ones as crashes, and always design for recovery.