Elixir: Pattern Matching
= is not assignment — it is a match operator. The left side is a
pattern; Elixir tries to make it equal the right side, binding variables as needed. This one idea
powers destructuring, branching, function dispatch, and even binary parsing.
The Match Operator
x = 1 # binds x to 1
1 = x # matches! 1 is not a variable, so it must equal x
2 = x # ** (MatchError) — no way to make 2 equal 1
{x, y} = {1, 2} # destructures a tuple: x = 1, y = 2
[a, b, c] = [1, 2, 3] # destructures a list
{:ok, value} = {:ok, 42} # matches the tagged-tuple convention
# A variable already bound can be re-bound, or *pinned* to require equality.
y = 99 # re-binds y (the default)
^y = 100 # ** (MatchError) — ^y means "must equal current y"
When a match fails, Elixir raises a MatchError. In production code you usually match
inside case or function heads, where failure selects the next clause instead of
crashing.
Destructuring Maps and Structs
user = %{name: "Ada", role: :admin, visits: 12}
%{name: name} = user # partial match: only requires the :name key
%{role: role} = user # role = :admin
# Structs match on their module (see the polymorphism lesson).
defmodule Report do
defstruct title: "", rows: 0
end
%Report{rows: rows} = %Report{title: "Q3", rows: 7}
rows #=> 7
Guards
Guards are extra conditions attached to a pattern. They use a restricted set of functions that the runtime guarantees to be side-effect free, so matching stays a pure, observable operation.
defmodule Access do
# Clauses are tried top to bottom; guards narrow each one.
def open(%{age: age}) when age >= 18, do: :granted
def open(%{age: age}) when age > 0 and age < 18, do: :denied
def open(_), do: {:error, :invalid} # catch-all: bad or missing data
end
Function Heads
Multiple clauses with pattern heads are Elixir's switch and its loop structure at the
same time: the runtime tries clauses top to bottom, and the first head that matches runs. Recursion
plus a base clause is the classic functional loop.
defmodule Math do
# Base clause: recursion stops at 0 — clause ORDER matters.
def sum_to(0), do: 0
def sum_to(n) when n > 0, do: n + sum_to(n - 1)
# Dispatching on message shape instead of if/else chains:
def handle({:add, a, b}), do: {:ok, a + b}
def handle({:div, _, 0}), do: {:error, :division_by_zero}
def handle({:div, a, b}), do: {:ok, a / b}
def handle(other), do: {:error, {:unknown, other}}
end
case, cond and with
Three branching forms, three situations. Use case to match a value,
cond to test conditions (the equivalent of if/else-if chains), and
with to chain matches and fail fast with a custom else.
# case: match on the shape of one value
case File.read("config.txt") do
{:ok, body} -> parse(body)
{:error, :enoent} -> "missing file" # match a SPECIFIC error
{:error, reason} -> {:error, reason} # bind any other reason
end
# cond: first truthy condition wins
cond do
temperature > 38 -> :fever
temperature > 37 -> :warm
true -> :normal # `true` acts as the else
end
# with: chain happy-path matches; the first non-match jumps to `else`
with {:ok, body} <- File.read("users.json"),
{:ok, users} <- JSON.decode(body) do
{:ok, length(users)}
else
# `else` re-matches whatever failed — often the raw error tuple
{:error, reason} -> {:error, reason}
end
Tagged Tuples, the Convention
Elixir functions that can fail return {:ok, value} or {:error, reason}
instead of raising. Matching forces the caller to acknowledge both paths — the compiler in your head.
Exceptions are reserved for genuinely unexpected, "should never happen" states, where crashing and
letting a supervisor restart the component is the
right answer.
Common Pitfalls
- Underscore vs pinned variable:
_discards;_rolebinds but marks unused;^rolerequires equality with an existing binding. - Clause order: a too-general head first (
def f(_)) silently shadows later specific clauses. - Map patterns are partial:
%{name: n} = usersucceeds even ifuserhas ten other keys — that is a feature, not a bug. withwithoutelsereturns the non-matching value verbatim — sometimes useful, often confusing; add theelse.
Practice
- Write
classify/1that accepts{:ok, n}(positive, zero, negative via guards),{:error, reason}, and anything else. - Implement
sum_to/1, then explain to yourself why the base clause must come first. - Convert a nested
caseintowithand compare the error handling.
Next: Functions & Recursion