Error Handling

Elixir error handling with ok/error tuples, exceptions, try/rescue, and custom errors

Elixir favors explicit error handling with {:ok, value} and {:error, reason} tuples over exceptions. Exceptions are reserved for truly unexpected situations.

The ok/error pattern

# Functions return tagged tuples
case File.read("config.txt") do
  {:ok, contents} -> process(contents)
  {:error, :enoent} -> IO.puts("File not found")
  {:error, reason} -> IO.puts("Error: #{inspect(reason)}")
end

# With can chain ok/error tuples
with {:ok, user} <- fetch_user(id),
     {:ok, token} <- create_token(user),
     {:ok, session} <- create_session(token) do
  {:ok, session}
else
  {:error, :not_found} -> {:error, "User not found"}
  error -> error  # pass through any other error
end

Bang (!) functions

Functions ending in ! raise on error instead of returning a tuple:

# Tuple version (safe)
File.read("missing.txt")    # => {:error, :enoent}

# Bang version (raises)
File.read!("missing.txt")   # => ** (File.Error) could not read file

# Other examples:
Map.fetch(map, :key)         # => {:ok, value} | :error
Map.fetch!(map, :key)        # => value | raises KeyError

Access.list_key(list, 0)     # => {:ok, value} | :error
Access.list_key!(list, 0)    # => value | raises

Raising exceptions

# With a message
raise "Something went wrong"

# With a specific error type
raise ArgumentError, "Invalid input: #{input}"

# Reraise (preserve original stacktrace)
try do
  risky()
rescue
  e -> reraise e, __STACKTRACE__
end

Custom exceptions

defmodule MyApp.HttpError do
  defexception [:message, :status_code]

  @impl true
  def message(%{status_code: code, message: msg}) do
    "HTTP #{code}: #{msg}"
  end
end

# Raising custom exception
raise MyApp.HttpError, status_code: 404, message: "Not found"

try / rescue / after

result = try do
  dangerous_operation()
rescue
  e in ArgumentError ->
    {:error, e.message}
  e in [File.Error, RuntimeError] ->
    {:error, "File or runtime error: #{inspect(e)}"}
  e ->
    # Catch-all (use sparingly)
    {:error, "Unexpected: #{inspect(e)}"}
after
  # Always runs, regardless of success or failure
  cleanup()
end

try / catch

For catching throw values (rare, used for control flow):

try do
  Enum.each(items, fn item ->
    if item == :stop, do: throw(:stopped)
    process(item)
  end)
  :all_processed
catch
  :throw, :stopped -> :stopped_early
end

try / else

Pattern match on the result of a try block:

try do
  {:ok, result}
else
  {:ok, value} -> value
  {:error, reason} -> handle_error(reason)
end

Common error types

Exception When
ArgumentError Invalid argument
ArithmeticError Bad math (e.g., div by zero)
KeyError Key not found in map/keyword
Enum.OutOfBoundsError Invalid index
File.Error Filesystem error
RuntimeError Generic error (raise "msg")
FunctionClauseError No matching function clause
MatchError Pattern match failure
CaseClauseError No matching case clause
CondClauseError No truthy cond condition
TryClauseError No matching try/else clause
ErlangError Raw Erlang error

Best practices

  1. Return {:ok, value} or {:error, reason} for expected failures (file not found, validation errors, etc.)
  2. Use exceptions for unexpected failures (programming errors, corrupt data)
  3. Use with to chain ok/error tuples instead of nested case
  4. Always include a catch-all clause in case and with during development
  5. Use bang (!) functions only when you want to crash on failure (e.g., config files that must exist)
  6. Use @spec to document return types (@spec fetch(id :: pos_integer()) :: {:ok, User.t()} | {:error, :not_found})

The “Happy Path” pattern

# Instead of nested case:
case fetch_user(id) do
  {:ok, user} ->
    case create_token(user) do
      {:ok, token} ->
        case send_email(user, token) do
          {:ok, _} -> {:ok, user}
          {:error, reason} -> {:error, reason}
        end
      {:error, reason} -> {:error, reason}
    end
  {:error, reason} -> {:error, reason}
end

# Use with:
with {:ok, user} <- fetch_user(id),
     {:ok, token} <- create_token(user),
     {:ok, _} <- send_email(user, token) do
  {:ok, user}
end
# Any {:error, reason} automatically returns {:error, reason}