DEV Community

Cover image for Learning Elixir: Error Handling with try/rescue/catch/after
João Paulo Abreu
João Paulo Abreu

Posted on

Learning Elixir: Error Handling with try/rescue/catch/after

I like to think of error handling in Elixir as having two different toolkits for two different kinds of problems. Tagged tuples — {:ok, value} and {:error, reason} — are like a polite conversation where both sides agree on what could go wrong ahead of time. But sometimes the unexpected happens: a process crashes, a library raises an exception, or you need to abort a deep computation with a throw. That is where try, catch, rescue, and after come in. They are like a safety net under a trapeze artist — you do not plan to fall, but you are glad the net is there when something goes wrong. In this article, I will explore when and how to use these constructs, and what I learned about keeping error handling intentional and readable.

Note: The examples in this article use Elixir 1.20.1. While most operations should work across different versions, some functionality might vary.

Table of Contents

Introduction

In our previous article about error handling basics, I explored the {:ok, value} and {:error, reason} pattern. That approach works wonderfully for predictable failures: a user not found, invalid input, a missing configuration key. But not all failures are predictable. Sometimes a library function raises an exception, an arithmetic operation divides by zero, or a remote call crashes the calling process. For those situations, Elixir provides try, rescue, catch, and after.

What I learned about these constructs:

  • Rescue catches raised exceptions — things like RuntimeError, ArgumentError, or custom exceptions
  • Catch handles throws and exits — different mechanisms for non-local control flow
  • After runs cleanup code — guaranteed to execute whether or not an error occurred
  • They complement tagged tuples — they do not replace them, but handle a different category of problems
  • Elixir discourages overuse — the philosophy is "let it crash" in many cases, so these tools are used sparingly

I found that understanding when not to use these constructs is just as important as knowing how they work.

When to Use Try/Rescue vs Tagged Tuples

The Core Distinction

I like to think of the difference this way: tagged tuples are for expected problems, while try/rescue is for unexpected problems that you cannot avoid.

defmodule Conversions do
  # Expected problem: user provides invalid input
  # Solution: return {:error, reason}
  def divide(a, b) when is_number(a) and is_number(b) and b != 0 do
    {:ok, a / b}
  end

  def divide(_a, 0), do: {:error, :division_by_zero}
  def divide(_a, _b), do: {:error, :invalid_input}

  # Unexpected problem: library raises an exception
  # Solution: use try/rescue
  def safe_parse_integer(string) do
    try do
      {:ok, String.to_integer(string)}
    rescue
      ArgumentError -> {:error, :not_an_integer}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Conversions.divide(10, 2)
{:ok, 5.0}

iex> Conversions.divide(10, 0)
{:error, :division_by_zero}

iex> Conversions.safe_parse_integer("42")
{:ok, 42}

iex> Conversions.safe_parse_integer("not_a_number")
{:error, :not_an_integer}
Enter fullscreen mode Exit fullscreen mode

When I Prefer Tagged Tuples

defmodule FileReader do
  # Better: explicit return values
  def read_config(path) do
    case File.read(path) do
      {:ok, contents} -> parse_config(contents)
      {:error, reason} -> {:error, {:file_read_failed, reason}}
    end
  end

  defp parse_config(contents) do
    # Parsing logic here
    {:ok, contents}
  end
end
Enter fullscreen mode Exit fullscreen mode

When I Use Try/Rescue

defmodule SafeCalculator do
  # Necessary: division raises an exception when divisor is zero
  def safe_div(a, b) do
    try do
      {:ok, a / b}
    rescue
      ArithmeticError -> {:error, :division_by_zero}
    end
  end

  # Another common case: parsing that may raise
  def parse_positive_integer(string) do
    try do
      value = String.to_integer(string)
      if value > 0, do: {:ok, value}, else: {:error, :not_positive}
    rescue
      ArgumentError -> {:error, :not_an_integer}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> SafeCalculator.safe_div(10, 2)
{:ok, 5.0}

iex> SafeCalculator.safe_div(10, 0)
{:error, :division_by_zero}

iex> SafeCalculator.parse_positive_integer("42")
{:ok, 42}

iex> SafeCalculator.parse_positive_integer("abc")
{:error, :not_an_integer}
Enter fullscreen mode Exit fullscreen mode

Understanding Exceptions in Elixir

Built-in Exception Types

Elixir has several built-in exception types that you might encounter:

# RuntimeError - general purpose exception
raise "Something went wrong"
# ** (RuntimeError) Something went wrong

# ArgumentError - wrong argument type or value
raise ArgumentError, message: "expected a positive integer"
# ** (ArgumentError) expected a positive integer

# ArithmeticError - math operations that fail
1 / 0
# ** (ArithmeticError) bad argument in arithmetic expression

# FunctionClauseError - no matching function clause
String.upcase(123