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
casefor one-off branching inside a function — typically the result of an IO call. - Use
condwhen the conditions are unrelated boolean checks rather than shapes of one value. It's the Elixir equivalent of anif/elif/elsechain.
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 insidewithis regular match (raises on failure). Mix them deliberately. - Without an
else, the failure value is returned as-is. With anelse, every non-matching value goes through it — even ones you didn't anticipate. Always include a catch-all_ -> ...if you useelse. withis 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/2controller action pattern-matches on the request shape. Bad shape → 400. - The
withchain runs the steps sequentially and shortcircuits on failure. - The
elseblock translates internal errors to HTTP responses. handle_event/1dispatches 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,
casefor one-off branching,condfor unrelated booleans. withflattens nested error handling. Always include a catch-all inelse.- 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.