6 min read
On this page

Pattern Matching in Practice

The first two files covered the mechanics. This one covers the patterns — the recurring shapes you'll see in production Elixir code, the conventions that keep teams sane, and the bits that take a few months to get comfortable with (binary patterns, the with macro, success/error pipelines).

If you've worked through the basics, this is the file that turns "I understand pattern matching" into "I think in patterns."

case Versus cond Versus Multiple Heads

Three ways to branch on input. They look interchangeable in toy examples but have meaningful trade-offs.

case matches a single value against patterns:

case Repo.get(User, id) do
  %User{role: :admin} -> :allow
  %User{role: :member, verified?: true} -> :allow
  %User{} -> :deny
  nil -> :not_found
end

cond evaluates boolean conditions in order:

cond do
  is_admin?(user) -> :allow
  user.verified? and not user.banned? -> :allow
  true -> :deny
end

Multiple function heads dispatch on the input shape:

def access(%User{role: :admin}), do: :allow
def access(%User{role: :member, verified?: true}), do: :allow
def access(%User{}), do: :deny
def access(nil), do: :not_found

Conventions I see hold up well in real codebases:

  • Use multiple function heads when the dispatch is conceptually part of the function's contract. They show up in documentation, are easy to test, and add new cases without touching existing code.
  • Use case for one-off branching inside a function — typically the result of an IO call.
  • Use cond when the conditions are unrelated boolean checks rather than shapes of one value. It's the Elixir equivalent of an if/elif/else chain.

If you reach for cond and find yourself testing a single variable's shape repeatedly, that's a smell. Convert to case or function heads.

The Universal Result Tuple

The single most important convention in Elixir is the result tuple: {:ok, value} for success, {:error, reason} for failure. Almost every library follows it. Almost every function you write should follow it.

defmodule Accounts do
  def fetch_user(id) do
    case Repo.get(User, id) do
      nil -> {:error, :not_found}
      user -> {:ok, user}
    end
  end

  def authenticate(email, password) do
    with {:ok, user} <- get_by_email(email),
         {:ok, user} <- check_password(user, password),
         {:ok, user} <- check_active(user) do
      {:ok, user}
    end
  end
end

The shape pattern-matches cleanly:

case Accounts.fetch_user(42) do
  {:ok, user} -> render_user(user)
  {:error, :not_found} -> render_404()
  {:error, reason} -> Logger.error("fetch failed: #{inspect(reason)}")
end

Two related conventions:

  • Bang functions (ending in !) raise on error instead of returning a tuple. Repo.get!/2, File.read!/1. Use them when failure should crash the process — typically in scripts or one-off code.
  • Predicate functions (ending in ?) return booleans, not tuples. User.admin?/1, String.contains?/2. Reserve ? for true/false answers, not result-bearing checks.

So a typical Elixir module exposes three flavors of the same idea:

def get(id), do: # returns {:ok, _} or {:error, _}
def get!(id), do: # returns the value or raises
def exists?(id), do: # returns true or false

Each is the right tool in different contexts.

The with Macro

case chains get ugly when each step depends on the previous one:

case fetch_user(id) do
  {:ok, user} ->
    case check_password(user, pw) do
      {:ok, user} ->
        case check_active(user) do
          {:ok, user} -> create_session(user)
          {:error, _} = err -> err
        end
      {:error, _} = err -> err
    end
  {:error, _} = err -> err
end

Three levels of nesting and most of the code is plumbing. with flattens it:

with {:ok, user} <- fetch_user(id),
     {:ok, user} <- check_password(user, pw),
     {:ok, user} <- check_active(user) do
  create_session(user)
end

Each <- is a pattern match. If it succeeds, the next line runs. If any one fails, the whole with returns the unmatched value. So if check_password/2 returns {:error, :wrong_password}, that's exactly what the with returns. No nesting, no manual error propagation.

You can add an else clause to handle specific failure cases:

with {:ok, user} <- fetch_user(id),
     {:ok, user} <- check_password(user, pw) do
  create_session(user)
else
  {:error, :not_found} -> {:error, :unknown_user}
  {:error, :wrong_password} -> {:error, :unauthorized}
  {:error, _} = err -> err
end

This is the cleanest way to write a multi-step process where each step can fail. Phoenix controllers, Ecto multi-step transactions, and pretty much every "do A, then B, then C, abort if any fails" workflow benefits.

A few with gotchas:

  • The <- arrow does pattern matching. The = operator inside with is regular match (raises on failure). Mix them deliberately.
  • Without an else, the failure value is returned as-is. With an else, every non-matching value goes through it — even ones you didn't anticipate. Always include a catch-all _ -> ... if you use else.
  • with is great up to about five steps. Beyond that, refactor into smaller functions.

Composing Patterns

Patterns nest. You can match deep into structures in one go:

def extract_user_email(%{
      "data" => %{
        "user" => %{"email" => email}
      }
    }) do
  {:ok, email}
end

def extract_user_email(_), do: {:error, :invalid_shape}

This validates the entire path in one match. Compare to a sequence of Map.get/2 calls and nil checks. The pattern form is shorter and forces you to think about the shape up front.

The cost: an unexpected JSON shape gives a FunctionClauseError. So pair it with a fallback clause that returns a clean error.

You can match and bind at multiple levels using = inside a pattern:

def process(%Order{items: [%Item{sku: "PREMIUM-" <> _ = sku} | _] = items} = order) do
  Logger.info("processing premium order #{order.id} starting with #{sku}")
  do_work(order, items)
end

That single function head pulls out the order, the items list, the first item's SKU, and validates the SKU starts with "PREMIUM-". You'd need a paragraph of imperative code to do the same checks.

Binary Pattern Matching

Binaries (which include strings) can be pattern-matched bit by bit. This is one of Elixir's superpowers — protocol parsing, file format decoding, and bit-level work that would be miserable in most languages becomes natural.

# parse a fixed-shape header
def parse_packet(<<version::8, type::8, length::16, payload::binary-size(length), rest::binary>>) do
  {:ok, %{version: version, type: type, payload: payload}, rest}
end

That single match pulls four fields out of a byte stream: a 1-byte version, a 1-byte type, a 2-byte length, and a payload of exactly that length. The rest of the binary is left over for the next iteration.

Strings work the same way because they're binaries:

def parse_email("user-" <> id), do: {:user, String.to_integer(id)}
def parse_email("admin-" <> id), do: {:admin, String.to_integer(id)}
def parse_email(_), do: :unknown

The "user-" <> id matches any binary that starts with "user-" and binds the rest to id. This is concatenation in patterns.

You can specify size, type, and unit on each segment:

<<r::8, g::8, b::8>> = <<255, 128, 0>>
# r = 255, g = 128, b = 0

<<header::binary-size(4), body::binary>> = <<"HEAD", "rest of message">>
# header = "HEAD", body = "rest of message"

<<value::little-integer-size(32)>> = <<1, 0, 0, 0>>
# value = 1 (little-endian 32-bit integer)

Real use cases:

  • HTTP/2 frame parsing.
  • Decoding Erlang term format from network sockets.
  • Reading binary file formats (PNG headers, MP3 frames).
  • Parsing custom wire protocols.
  • Working with bitfields in embedded contexts (Nerves).

You won't write binary patterns daily, but when you need them, they're a major reason the BEAM is good for protocol work.

Real-World Composition

Putting it all together — here's a slice of code that handles incoming webhook requests, written in idiomatic Elixir:

defmodule WebhookController do
  use MyAppWeb, :controller

  def receive(conn, %{"event" => event_type, "data" => data} = params) do
    with {:ok, signature} <- get_signature(conn),
         :ok <- verify_signature(conn.body_params, signature),
         {:ok, event} <- parse_event(event_type, data),
         {:ok, _result} <- handle_event(event) do
      send_resp(conn, 200, "ok")
    else
      {:error, :invalid_signature} ->
        send_resp(conn, 401, "unauthorized")

      {:error, :unknown_event} ->
        Logger.warning("unknown event type: #{event_type}")
        send_resp(conn, 200, "ignored")

      {:error, reason} ->
        Logger.error("webhook failed: #{inspect(reason)}")
        send_resp(conn, 500, "internal error")
    end
  end

  def receive(conn, _params), do: send_resp(conn, 400, "bad request")

  defp handle_event(%StripeEvent{type: "invoice.paid"} = event), do: Billing.mark_paid(event)
  defp handle_event(%StripeEvent{type: "customer.subscription.created"} = event), do: Subs.create(event)
  defp handle_event(%StripeEvent{type: "customer.subscription.deleted"} = event), do: Subs.cancel(event)
  defp handle_event(_), do: {:error, :unknown_event}
end

Note what's happening:

  • The receive/2 controller action pattern-matches on the request shape. Bad shape → 400.
  • The with chain runs the steps sequentially and shortcircuits on failure.
  • The else block translates internal errors to HTTP responses.
  • handle_event/1 dispatches on the event type via struct pattern matching, with a fallback for unknowns.

There's no error-handling boilerplate. Every code path is a pattern. The function clauses are small, named, and individually testable. This is what idiomatic Elixir code looks like at scale.

Common Pitfalls

Forgetting the catch-all in with...else. If else matches some failures but not others, an unmatched value raises WithClauseError. Always include a _ -> ... clause unless you're certain you've enumerated every possible failure.

Treating with like a try/catch. with is for chaining successful operations. Exceptions still propagate normally — with doesn't catch them. If an operation raises, the with raises. Use try/rescue if you need to catch.

Pattern matching on JSON keys with the wrong type. Phoenix params come in as string keys, not atom keys. %{"email" => email} works on params; %{email: email} doesn't. Internal data tends to use atoms. Be deliberate about which side of the boundary you're on.

Binary patterns with the wrong size. Forgetting binary-size(n) falls back to a default that probably isn't what you want. When in doubt, write the size explicitly. The runtime will error helpfully if the binary is shorter than the pattern requires.

Result tuples that aren't actually result tuples. {:ok, value, extra} breaks every consumer who expects 2-tuples. Stick to {:ok, value} and {:error, reason}. If you need to return multiple values on success, put them in a map or struct as the value.

Over-deep pattern matching that breaks on minor schema changes. A 5-level-deep pattern is a 5-level-deep coupling to the input shape. For external APIs that change, prefer a shallow match plus explicit extraction.

Key Takeaways

  • Result tuples ({:ok, _} and {:error, _}) are the universal convention. Match against them at every layer.
  • Use multiple function heads for dispatch, case for one-off branching, cond for unrelated booleans.
  • with flattens nested error handling. Always include a catch-all in else.
  • Bang functions (!) raise; predicate functions (?) return booleans. Reserve them for their proper roles.
  • Patterns compose deeply, including binding intermediate values with = inside the pattern.
  • Binary pattern matching makes protocol parsing readable and fast. Reach for it when working with raw byte data.
  • Idiomatic Elixir replaces error-checking boilerplate with patterns and with. Lean into the convention.