Exceptions are a core aspect of programming, and a way to signal when something goes wrong with a program. An exception could result from a simple error, or your program might crash because of underlying constraints. Exceptions are not necessarily bad, though — they are fundamental to any working application.
Let’s see what our options are for handling exceptions in Elixir.
Raising Exceptions in Elixir
The Elixir (and Erlang) community is generally quite amenable to exceptions: “let it crash” is a common phrase. This is due, in part, to the excellent OTP primitives in Erlang/Elixir. The OTP primitives allow us to create supervisors that manage and restart processes (or a group of related processes) on failure.
Exceptions can occur when something unexpected happens in your Elixir application. For example, division by zero raises an ArithmeticError
.
iex> 1 / 0
** (ArithmeticError) bad argument in arithmetic expression: 1 / 0
:erlang./(1, 0)
iex:1: (file)
Exceptions can also be raised manually:
iex> raise "BOOM"
** (RuntimeError) BOOM
iex:1: (file)
By default, raise
creates a RuntimeError
. You can also raise other errors by using raise/2
:
defmodule Math do
def div(a, b) do
if b == 0, do: raise ArgumentError, message: "cannot divide by zero"
a / b
end
end
iex> Math.div(1, 0)
** (ArgumentError) cannot divide by zero
iex:4: Math.div/2
iex:3: (file)
A function call that doesn’t match a defined function raises an ArgumentError
by default:
defmodule Math do
def div(a, b) when b != 0 do
a / b
end
end
iex> Math.div(1, 0)
** (FunctionClauseError) no function clause matching in Math.div/2
The following arguments were given to Math.div/2:
# 1
1
# 2
0
Handling Elixir Exceptions
Elixir provides the try
-rescue
construct to handle exceptions:
try do
raise "foo"
rescue
e in RuntimeError -> IO.inspect(e)
end
This prints %RuntimeError{message: "foo"}
in the console but doesn’t crash anything. Use this when you want to recover from exceptions in Elixir. It is also possible to skip binding the variable if it is unnecessary. For example:
try do
raise "foo"
rescue
RuntimeError -> IO.puts("something bad happened")
end
In both of the above examples, we only rescue
RuntimeError
. If there is another error, it will still raise an exception. To rescue all exceptions raised inside the try
block, use the rescue
without an error type, like this:
try do
1 / 0
rescue
e -> IO.inspect(e)
end
Running the above prints %ArithmeticError{message: "bad argument in arithmetic expression"}
. If you want to perform different actions based on different exceptions, just add more clauses to the rescue branch.
try do
1 / 0
rescue
RuntimeError -> IO.puts("Runtime Error")
ArithmeticError -> IO.puts("Arithmetic Error")
_e -> IO.puts("Unknown error")
end
While rescuing all errors (without using a specific type) sounds tempting, it is a good practice to rely on specific exceptions because:
- You can perform different recovery tasks for different exceptions.
- It saves you from future breaking changes or programming errors going unnoticed.
For example, consider the below function:
defmodule Config do
def get(file) do
try do
contents = File.read!(file)
parse!(contents)
rescue
_e -> %{some: "default config"}
end
end
end
It reads and parses a config file, returning the result. When written, it uses a generic clause to rescue from missing file-related errors. Let’s say that a while later, the input file gets corrupted somehow (e.g., a simple missing }
or an extra ,
in a JSON file), and now there are parsing errors. Our function will still work without raising any issues, and we will be left guessing why it doesn’t work even though there’s an existing file.
Another common practice in the Elixir community is to use ok
/error
tuples to signal errors instead of raising exceptions (for both internal and external libraries). So, instead of returning a simple result
or raising an exception on an issue, a function in Elixir will return {:ok, result}
on success and {:error, reason}
on failure.
This means that most of the time, a case
block will be preferable to try/rescue. For example, the above File.read!
can be replaced by:
case File.read(file) do
{:ok, contents} -> parse!(contents)
{:error, reason} -> %{some: "default config"}
end
In practice, you should reach out for try
/raise
only in exceptional cases, never as a means of control flow. This is quite different from some other popular languages like Ruby or Java, where unexpected operations usually raise an error, then handle control flow for cases like non-existent files.
Re-raising Exceptions
Sometimes, we just want to know that there is an exception but not rescue from it — for example, to log an exception before allowing the process to crash or to wrap the exception into something more useful/understandable to the user.
This is where reraise
can be helpful, as it preserves an exception's existing stack trace:
try do
1 / 0
rescue
e ->
IO.puts(Exception.format(:error, e, __STACKTRACE__))
reraise e, __STACKTRACE__
end
Here, we use the __STACKTRACE__
to retrieve the original trace of the exception, including its origin and the full call stack.
In a real-world application, you might use this to report a metric / send an exception to a third-party exception tracking service, or log something informative to a third-party logging service. For example, you might send telemetry data to AppSignal under these circumstances.
In addition, it is also common practice to have custom exceptions for cases where unexpected things happen in libraries. reraise/3
can be useful here:
defmodule DivisionByZeroError do
defexception [:message]
end
defmodule Math do
def div(a, b) do
try do
a / b
rescue
e in ArithmeticError ->
reraise DivisionByZeroError, [message: e.message], __STACKTRACE__
end
end
end
Now using Math.div(1, 0)
will raise a DivisionByZeroError
instead of a generic ArithmeticError
.
Rescue And Catch in Elixir
In Elixir, try
/raise
/rescue
and try
/throw
/catch
are different. raise
and rescue
are used for exception handling, whereas throw
and catch
are used for control flow.
A throw
stops code execution (much like a return
— the difference being that it bubbles up until a catch
is encountered). It is rarely needed and is only an escape hatch to be used when an API doesn’t provide an option to do something.
For example, let's say that there’s an API that produces numbers and invokes a callback:
defmodule Producer do
def produce(callback) do
Enum.each((1..100) , fn x -> callback.(x) end)
end
end
(This is just an example. Assume that with the real API, it is much more expensive to produce each number, and it produces an infinite list of numbers.)
We want to find the first number divisible by 13, since we don’t have any other apparent way of finding that number and stopping the production at that point. Let’s see how we can use throw
/catch
to do this:
try do
Producer.produce(fn x ->
if rem(x, 13) == 0 do
throw(x)
end
end)
catch
x -> IO.puts("First number divisible by 13 is #{x}")
end
It's worth stating that this is a bit of a contrived case, and most good APIs are designed in such a way that you never need to reach out for throw
/catch
.
After and Else Blocks in Elixir
You can use after
and else
blocks with all try
blocks. An after
block is called after processing all other blocks related to the try
, regardless of whether any of the blocks raised an error. This is useful for performing any required clean-up operations.
For example, the below code creates a new producer and makes some results. If there’s an error during production, it will raise an exception. But the after
block ensures that the producer is still disposed of regardless.
producer = Producer.new
try do
Producer.produce!(producer)
after
Producer.dispose(producer)
end
On the other hand, an else
block is called only if the try
block is completed without raising an error. The else
block receives the try
block's result. The return value from the else
block is the final return value of the try
. For example:
try do
File.read!("/path/to/file")
rescue
File.Error -> :not_found
else
"a" -> :a
_other -> :other
end
In the above code, if the file exists with content a
, the result will be :a
. The result is :not_found
if the file doesn’t exist, and :other
if the content is anything else.
Exceptions and Processes
No discussion about Elixir exceptions is complete without examining their impact on processes. Any unhandled exceptions cause a process to exit.
With this in mind, let’s revisit the “let it crash” strategy. If we don’t handle an exception from a process, it will crash. This is good in a way because:
- Since all processes are separate from each other, an exception or unhandled crash from one process can never affect the state of another process.
- In most sophisticated Elixir applications, all the processes run under a supervision tree. So, an unexpected exit will restart the process (depending on the supervision strategy) and any linked processes with a clean slate.
In most cases, intermittent issues will resolve themselves in the next run. This is much easier (and usually also cleaner) than handling each failure separately and performing a recovery step.
If you want to learn more about supervisors, I suggest the official Elixir Supervisor and Application guide as a great starting point.
Monitoring Exceptions
As we've seen, exceptions are a fundamental part of any application. Handling them is good, but sometimes, letting them crash a process is even better.
But in all cases, it is better to monitor exceptions happening in the real world so that you can take action if there’s something concerning (for example, a developer error that crashes an app).
This is where AppSignal for Elixir can help. Once set up, it automatically records all errors and can also trigger alerts based on predefined conditions.
Here's an example of an individual error you can get to from the "Errors" -> "Issue list" in AppSignal for debugging:
Check out the AppSignal for Elixir installation guide to get started.
Wrapping Up
In this post, we explored how errors are treated in Elixir and how to recover from them. We also saw that sometimes it is better to just let a process crash and be restarted through the supervisor than to manually perform recovery steps for all possible exceptions.
Elixir’s API makes a clear distinction between functions that raise an exception (usually ending in !
) and functions that return a success/error tuple. If you are a library author, it is better to provide both options for users.
Reach out for the tuple-based methods when you need to handle error cases separately. In Elixir, we rarely use try
blocks for control flow — the !
functions are for when we want a process to crash on unexpected events.
Until next time, happy coding!
P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!
Top comments (0)