6 min read
On this page

Tagged Tuples and with

The dominant style of error handling in Elixir isn't exceptions. It's the {:ok, value} / {:error, reason} convention — tagged tuples that carry success or failure as data. This convention is so consistent across the standard library and the ecosystem that you can read code in libraries you've never seen and immediately understand its error semantics.

The reason this style won out: errors as data are easier to compose, log, transform, and test than errors as control-flow exceptions. And once you have the convention, with lets you chain multiple fallible operations cleanly without nested case statements.

The Convention

Functions that can fail in expected ways return either:

  • {:ok, value} on success
  • {:error, reason} on failure

Functions that can't meaningfully fail just return the value. Functions where success doesn't have a value return :ok.

# can fail, returns value on success
File.read("config.toml")           # {:ok, contents} | {:error, :enoent}

# can fail, returns just :ok on success
File.write("out.txt", contents)    # :ok | {:error, reason}

# can't meaningfully fail
String.upcase("hello")             # "HELLO"

The reason this beats exceptions for expected failures:

  1. Type signatures are honest. The return type tells you the function might fail without you having to read the source or docs.
  2. Composable. Tuples are values. They flow through pipelines, get passed to callbacks, get logged.
  3. No invisible control flow. A case on the result is local and explicit. An exception jumps somewhere else — you have to read the whole call stack to know where.
  4. Pattern matching does the work. No try/catch noise.
case File.read("config.toml") do
  {:ok, contents} ->
    parse(contents)

  {:error, :enoent} ->
    default_config()

  {:error, reason} ->
    Logger.error("config read failed: #{inspect(reason)}")
    default_config()
end

Reasons Are Atoms (Mostly)

The second element of an error tuple is conventionally an atom or a small structured value. Atoms are cheap to pattern match on, easy to log, and clearly enumerable.

{:error, :not_found}
{:error, :unauthorized}
{:error, :rate_limited}
{:error, :timeout}

When you need more detail, use a tuple or a struct:

{:error, {:validation, [email: :required, age: :must_be_positive]}}
{:error, %MyApp.PaymentError{code: "card_declined"}}

What you don't want is unstructured strings as the reason:

# avoid — opaque, hard to match on, hard to translate
{:error, "user with that email already exists"}

Strings are for humans to read. Atoms and structured tuples are for code to handle. If you need a human message, build it from the structured reason at the point you display it.

The Nested Case Problem

Without with, chaining fallible operations means nesting:

def create_account(params) do
  case validate(params) do
    {:ok, valid} ->
      case Repo.insert(valid) do
        {:ok, user} ->
          case send_welcome_email(user) do
            {:ok, _} ->
              case Billing.subscribe(user, params.plan) do
                {:ok, sub} -> {:ok, %{user: user, subscription: sub}}
                {:error, reason} -> {:error, {:billing, reason}}
              end
            {:error, reason} -> {:error, {:email, reason}}
          end
        {:error, changeset} -> {:error, {:db, changeset}}
      end
    {:error, reason} -> {:error, {:validation, reason}}
  end
end

This is unreadable. Every layer of nesting is the same pattern: success continues, failure exits with the error tagged. The shape of the code shouldn't be a stair-step.

with to the Rescue

with is a control flow construct that lets you chain pattern matches, with a single fallback for any that fail:

def create_account(params) do
  with {:ok, valid} <- validate(params),
       {:ok, user} <- Repo.insert(valid),
       {:ok, _} <- send_welcome_email(user),
       {:ok, sub} <- Billing.subscribe(user, params.plan) do
    {:ok, %{user: user, subscription: sub}}
  end
end

Each <- is a pattern match. If they all succeed, the body runs. If any fails — meaning the right-hand side doesn't match the left — with returns the value that didn't match.

For the example above, if Repo.insert/1 returns {:error, changeset}, that's what the entire with expression returns. No nesting, no boilerplate, no dropped errors.

The else Clause

If you want to transform the failure, add an else:

def create_account(params) do
  with {:ok, valid} <- validate(params),
       {:ok, user} <- Repo.insert(valid),
       {:ok, _} <- send_welcome_email(user),
       {:ok, sub} <- Billing.subscribe(user, params.plan) do
    {:ok, %{user: user, subscription: sub}}
  else
    {:error, %Ecto.Changeset{} = cs} -> {:error, {:validation, cs}}
    {:error, :rate_limited} -> {:error, {:billing, :rate_limited}}
    {:error, reason} -> {:error, reason}
  end
end

The else runs if any <- clause fails to match. It's a case over the failing value.

A common style: skip the else and let the original error propagate. The caller can pattern match on it if needed. Adding an else should be a deliberate choice — it usually means you're translating between layers (e.g., wrapping a low-level error with a higher-level meaning).

Plain Expressions in with

You can interleave = matches that don't fail:

with {:ok, user} <- Accounts.find(id),
     :ok <- check_permissions(user, action),
     payload = build_payload(user),
     {:ok, response} <- API.post(payload) do
  {:ok, response}
end

The payload = build_payload(user) doesn't have an <- — it's not pattern-matching against {:ok, _}, it's just a binding. Use = for non-fallible steps; use <- for steps that might fail.

Bang Functions

Many standard library functions come in pairs: a regular version returning {:ok, _} / {:error, _}, and a bang version (!) that raises on failure.

File.read("config.toml")    # {:ok, contents} | {:error, reason}
File.read!("config.toml")   # contents | raises File.Error

Integer.parse("42")         # {42, ""} | :error  (this one's a special case)
String.to_integer("42")     # 42 | raises ArgumentError

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

Use the bang version when:

  • Failure means there's a bug in your code or your environment.
  • You want to crash and let supervision handle it.
  • You're confident the operation should succeed.

Use the non-bang version when:

  • Failure is a normal outcome you want to handle.
  • You want to use with to chain operations.
  • You're at an API boundary and want errors as data.

A common pattern: bang functions in startup code, non-bang functions in request handlers. Booting your app with priv/seeds.json missing is a deployment bug — File.read! and crash. Handling a request where the user provided a bad ID is a normal outcome — Accounts.fetch/1 returning {:error, :not_found}.

Composing With Pipes

with and pipes serve different purposes. Use pipes when each step has a single non-failing return type. Use with when steps can fail and you want to short-circuit.

# Pipe — each step transforms the value, no failure paths
"  hello world  "
|> String.trim()
|> String.upcase()
|> String.split(" ")

# with — each step might fail
with {:ok, raw} <- File.read(path),
     {:ok, json} <- Jason.decode(raw),
     {:ok, parsed} <- MyApp.Schema.cast(json) do
  {:ok, parsed}
end

You'll sometimes want to mix them — pipe a value through some pure transformations, then with a fallible operation. Both have their place. The mistake is forcing one when the other is cleaner.

Real Example: Order Processing

defmodule MyApp.Orders do
  alias MyApp.{Accounts, Inventory, Payments, Shipping, Repo}

  def place_order(user_id, items, payment_method) do
    with {:ok, user} <- Accounts.fetch(user_id),
         {:ok, reserved} <- Inventory.reserve(items),
         {:ok, charge} <- Payments.charge(user, total(reserved), payment_method),
         {:ok, order} <- Repo.insert(build_order(user, reserved, charge)),
         {:ok, _} <- Shipping.create_label(order) do
      {:ok, order}
    else
      {:error, :insufficient_stock} = err ->
        # nothing to roll back yet
        err

      {:error, :payment_declined} = err ->
        Inventory.release(items)
        err

      {:error, %Ecto.Changeset{}} = err ->
        Inventory.release(items)
        Payments.refund_last(user_id)
        err

      {:error, _shipping_error} = err ->
        # order is created but no label — handle async
        Logger.error("shipping label failed for order, will retry")
        err
    end
  end
end

Each step might fail with a different error. The else lets us handle each failure mode appropriately, including rolling back side effects. Without with, this would be either a giant nested case or a bunch of helper functions that exist only to control flow.

Common Pitfalls

Forgetting that with's default is to return the unmatched value. If Repo.insert/1 returns {:error, changeset} and you don't have an else, your with expression returns {:error, changeset}. That's usually fine — but if a caller is expecting {:error, atom}, they'll be surprised.

Catching too much in else. A bare _ -> {:error, :failed} catches everything, including ones you should re-raise. The else should match specific shapes; let unknown shapes propagate.

Mixing <- and = randomly. <- is for "this might fail; if so, exit with." = is for "this can't fail; bind and continue." Confusing them produces surprising behavior — = won't trigger the else on a mismatch, it'll just MatchError.

Using with for a single fallible step. If you have one operation that might fail, case is clearer. with shines for chains of three or more.

Returning naked errors. {:error, "something failed"} with a string is hard to handle programmatically. Use atoms or structured values; build human messages at the display boundary.

Ignoring the standard signature. Returning {:ok, value} for success but nil for failure breaks every tool that expects the convention. Pick the convention and stick with it.

Key Takeaways

  • {:ok, value} and {:error, reason} are the universal convention for fallible operations.
  • Reasons should be atoms or structured values, not strings — strings are for users, not code.
  • with chains pattern matches and exits early on the first failure. It replaces nested case blocks.
  • Use <- for fallible steps, = for non-fallible bindings. The else clause is for translating errors, not catching them silently.
  • Bang functions raise on failure — use them when failure means a bug; use the non-bang version when failure is part of the API.
  • Don't over-handle errors. Letting {:error, _} propagate is often the right answer; the caller decides what to do.