← Back to DevBytes

Error Handling Patterns in Elixir

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

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 case nesting is a strong signal to refactor with with or separate functions.
  • Ignoring the supervision tree: If you find yourself wrapping every function call in try/rescue, reconsider your supervision strategy.
  • Mixing throw with regular control flow: throw is 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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles