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 fromraise "message".ArgumentError— a function got an argument it can't handle.KeyError— whatMap.fetch!/2raises 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
rescuecatches exceptions raised withraise.catchcatchesthrowandexitsignals — separate from exceptions.afteralways 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, _}, justcaseon the result. - For control over what crashes, use the non-bang variant of a function (
Map.fetch/2instead ofMap.fetch!/2). - For cleanup, prefer constructs that handle it for you —
File.stream!withStream.run,Repo.transactionwith 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:
-
Use tagged tuples for expected failures — anything that's part of the function's API contract.
File.read/1returns{:ok, contents}or{:error, :enoent}because not finding a file is a normal outcome. -
Use exceptions for programmer errors and exceptional conditions — bugs, invariant violations, configuration mistakes, things that shouldn't happen if the code is correct.
-
Provide both — many standard library functions have a non-bang version that returns a tuple and a bang version that raises.
File.read/1andFile.read!/1.Integer.parse/1andString.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 byrescue. For exceptional conditions. - Exit (via
exit/1or process death) — caught bycatch :exit, reason. The mechanism behind process linking and supervision. - Throw (via
throw/1) — caught bycatch :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
raisefor programmer errors and exceptional conditions; tagged tuples for expected failures.defexceptiondefines 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.