6 min read
On this page

Errors and Exceptions

Even though Elixir's preferred error style is tagged tuples and supervised crashes, the language still has a real exception system. You'll encounter it whenever something genuinely goes wrong — division by zero, a missing key with Map.fetch!, a bad pattern match, a failed file open. Understanding when to use exceptions, when to define your own, and when to leave them alone is part of becoming fluent.

The short version: exceptions are for programmer errors and truly exceptional conditions, not for control flow on expected outcomes.

Raising Errors

raise throws an exception. The simplest form takes a string message:

raise "something went wrong"

This raises a RuntimeError. You can also raise a specific exception type:

raise ArgumentError, message: "expected a positive integer"

Or an exception module with arguments:

raise File.Error, action: "read", path: "/etc/passwd", reason: :eacces

The raise/1 and raise/2 forms are macros — they capture the location for stack traces. You can also reraise/2 to preserve the original stack trace when re-raising in a rescue clause:

try do
  do_something()
rescue
  e ->
    Logger.error("operation failed: #{inspect(e)}")
    reraise(e, __STACKTRACE__)
end

Built-in Exception Types

A small handful of built-ins cover most situations:

  • RuntimeError — generic, what you get from raise "message".
  • ArgumentError — a function got an argument it can't handle.
  • KeyError — what Map.fetch!/2 raises on a missing key.
  • MatchError — a pattern match failed.
  • FunctionClauseError — no function clause matched.
  • ArithmeticError — division by zero, etc.
  • File.Error — file operation failures.

You almost never have to think about these — they get raised automatically when their conditions occur. You just see them in logs, fix the bug, ship.

Defining Custom Exceptions

When you want a domain-specific error type, use defexception:

defmodule MyApp.PaymentError do
  defexception [:code, :message, :provider_response]

  @impl true
  def message(%__MODULE__{code: code, message: msg}) do
    "payment failed (#{code}): #{msg}"
  end
end

Now you can raise it with structured data:

raise MyApp.PaymentError,
  code: "card_declined",
  message: "insufficient funds",
  provider_response: response

And rescue it specifically:

try do
  process_payment(order)
rescue
  e in MyApp.PaymentError ->
    Logger.warn("payment failed: #{e.code}")
    {:error, e.code}
end

The :message field is special — Exception.message/1 looks for it by default. If you have a more complex message format, define message/1 as shown above.

In practice, custom exceptions are most useful in libraries where you want callers to be able to distinguish error types, and in places where the alternative (deeply nested tagged tuples) would obscure the structure. For most application code, tagged tuples are still the better tool.

try / rescue / after

try is the construct for catching exceptions. It has three optional sections:

try do
  do_something_risky()
rescue
  e in ArgumentError ->
    {:error, :bad_arg}
  e in [KeyError, MatchError] ->
    {:error, :data_shape}
  e ->
    {:error, {:unknown, e}}
catch
  :exit, reason ->
    {:error, {:exit, reason}}
  :throw, value ->
    {:error, {:thrown, value}}
after
  cleanup()
end
  • rescue catches exceptions raised with raise.
  • catch catches throw and exit signals — separate from exceptions.
  • after always runs, exception or not. Use for cleanup that must happen.

Most code doesn't need try. The language has cleaner alternatives:

  • For functions that already return {:ok, _} or {:error, _}, just case on the result.
  • For control over what crashes, use the non-bang variant of a function (Map.fetch/2 instead of Map.fetch!/2).
  • For cleanup, prefer constructs that handle it for you — File.stream! with Stream.run, Repo.transaction with rollback, etc.

A reasonable rule: if your code has more than one or two try/rescue blocks, you're probably catching things you should let crash, or you're using exceptions where tagged tuples would be cleaner.

When to Use Exceptions

The Elixir convention, codified loosely in the standard library, is:

  1. Use tagged tuples for expected failures — anything that's part of the function's API contract. File.read/1 returns {:ok, contents} or {:error, :enoent} because not finding a file is a normal outcome.

  2. Use exceptions for programmer errors and exceptional conditions — bugs, invariant violations, configuration mistakes, things that shouldn't happen if the code is correct.

  3. Provide both — many standard library functions have a non-bang version that returns a tuple and a bang version that raises. File.read/1 and File.read!/1. Integer.parse/1 and String.to_integer/1. The bang version is for "I'm confident this won't fail; if it does, that's a bug."

# Tagged tuple — caller decides what to do
case File.read("config.toml") do
  {:ok, contents} -> parse(contents)
  {:error, :enoent} -> default_config()
  {:error, reason} -> log_and_default(reason)
end

# Bang version — caller treats failure as a crash-worthy bug
contents = File.read!("priv/required_data.json")

The bang version isn't "lazy error handling." It's a deliberate choice: this file must exist for the system to work. If it doesn't, crash and let supervision figure it out.

Exit Signals

Beyond exceptions, BEAM has a separate mechanism: exit signals. When a process dies, it sends an exit signal to its linked processes. Normally, this kills them too — that's how supervision works.

You can catch exit signals at the boundary by trapping exits:

def init(state) do
  Process.flag(:trap_exit, true)
  {:ok, state}
end

def handle_info({:EXIT, pid, reason}, state) do
  Logger.warn("linked process #{inspect(pid)} died: #{inspect(reason)}")
  {:noreply, state}
end

When a process traps exits, exit signals from linked processes arrive as regular {:EXIT, pid, reason} messages instead of killing it. This is what supervisors do internally — they trap exits, observe child deaths, and decide whether to restart.

You should rarely trap exits in your own code. Doing so opts your process out of the supervision system. The cases where it makes sense are mostly when you're building infrastructure that needs to survive child failures while observing them — supervisors, pool managers, custom control planes.

exit/1 itself raises an exit signal:

exit(:normal)        # clean shutdown, doesn't kill linked processes
exit(:shutdown)      # also clean shutdown for supervisors
exit(:bad_thing)     # abnormal exit, propagates to linked processes

Process.exit(pid, :kill) is the unconditional kill — it can't be trapped. Use sparingly; it bypasses cleanup.

Errors vs Exits vs Throws

Three different things, often conflated:

  • Error (raised with raise) — caught by rescue. For exceptional conditions.
  • Exit (via exit/1 or process death) — caught by catch :exit, reason. The mechanism behind process linking and supervision.
  • Throw (via throw/1) — caught by catch :throw, value. A non-local return mechanism. Almost never used in idiomatic Elixir.

In application code, you'll mostly see errors. Exits show up at process boundaries. Throws are essentially never used outside of a few specific library patterns.

A Real Example

Here's a payment processor that uses exceptions correctly:

defmodule MyApp.Payments do
  defmodule InvalidAmountError do
    defexception [:amount, :currency]

    @impl true
    def message(%{amount: a, currency: c}) do
      "invalid amount #{inspect(a)} for currency #{c}"
    end
  end

  # bang version — crashes on programmer error
  def charge!(card, amount, currency) when amount > 0 do
    case validate_currency(currency) do
      :ok -> do_charge(card, amount, currency)
      :error -> raise InvalidAmountError, amount: amount, currency: currency
    end
  end

  def charge!(_card, amount, currency) do
    raise InvalidAmountError, amount: amount, currency: currency
  end

  # tagged tuple version — for expected failures (network, declines)
  def charge(card, amount, currency) do
    try do
      {:ok, charge!(card, amount, currency)}
    rescue
      e in InvalidAmountError -> {:error, {:invalid_input, e}}
    catch
      :exit, {:timeout, _} -> {:error, :provider_timeout}
    end
  end
end

charge!/3 raises on bad input — that's a bug in the caller. charge/3 wraps it in a tagged tuple for the cases where the caller wants to handle errors as data. Note that do_charge/3 returning {:error, :card_declined} flows through naturally — only the bang errors get caught.

Common Pitfalls

Using exceptions for control flow. try/rescue to test whether something will succeed is slow, ugly, and hides intent. If a function might fail in an expected way, give it a non-raising variant.

Swallowing exceptions with bare rescue. rescue _ -> nil catches everything, including bugs you'd want to see. Always rescue specific types when possible, and never silently discard the error.

Defining a custom exception per kind of business failure. A UserNotFoundError, EmailTakenError, PasswordTooShortError — these are expected outcomes, not exceptions. Use atoms in tagged tuples: {:error, :user_not_found}, {:error, :email_taken}.

Trapping exits in regular GenServers. This breaks the supervision contract. The supervisor expects the GenServer to die on linked failures so it can restart it; trapping exits silently opts out.

Forgetting that bang functions raise inside callbacks. Map.fetch!/2 inside handle_call/3 will crash the GenServer on a missing key. Sometimes that's what you want; sometimes it isn't. Be deliberate about which.

Key Takeaways

  • raise for programmer errors and exceptional conditions; tagged tuples for expected failures.
  • defexception defines custom exception types with structured data.
  • Most code doesn't need try/rescue — use tagged tuples and let supervisors handle crashes.
  • The bang/non-bang convention (!) signals "this might raise" — used deliberately, not as a shortcut.
  • Errors, exits, and throws are three separate mechanisms. Exits are the foundation of process linking and supervision.
  • Don't swallow exceptions, don't use them for control flow, and don't trap exits without a real reason.