Pattern Matching Basics
The = operator in Elixir doesn't assign. It matches. This is the single biggest mental shift for newcomers, and it changes how you write code at every level. Once you internalize that = asserts a shape and binds variables along the way, the rest of Elixir starts to make sense — function clauses, control flow, error handling, even GenServer callbacks all lean on the same machinery.
If you're coming from another language and find yourself thinking "this is just destructuring," that's a useful starting point. But Elixir's pattern matching goes further: it's used at every level, including dispatch, and the same syntax works for any data shape.
The Match Operator
iex> x = 1
1
iex> 1 = x
1
iex> 2 = x
** (MatchError) no match of right hand side value: 1
Read = as "the left side must match the right side." If the left contains an unbound variable, that variable gets bound to whatever the right side has at that position. If the left has a literal, that literal must equal the right side or the match fails.
This is why people call = the "match operator" rather than "assignment." It's an assertion that happens to bind variables as a side effect.
Once a variable is bound, you can rebind it later — Elixir doesn't enforce single-assignment in scopes:
iex> x = 1
1
iex> x = 2
2
But within a single match, a variable is bound once. We'll see how to match against an existing value (rather than rebinding) in the next file when we cover the pin operator.
Destructuring
The simplest useful application: pulling values out of a structured shape.
iex> {a, b, c} = {1, 2, 3}
iex> a
1
iex> c
3
iex> {:ok, value} = {:ok, "found it"}
iex> value
"found it"
iex> {:ok, value} = {:error, "not found"}
** (MatchError) no match of right hand side value: {:error, "not found"}
The last case is the important one. The match on {:ok, value} only succeeds when the right side is a 2-tuple with :ok as its first element. Otherwise, it raises. This is how you write code that loudly fails when assumptions break.
In real code, you usually want to handle both branches, so you don't use bare =. You use case:
case File.read("config.json") do
{:ok, contents} -> parse(contents)
{:error, :enoent} -> use_defaults()
{:error, reason} -> raise "config read failed: #{inspect(reason)}"
end
Each arm of the case is a pattern. The first one that matches wins. If none match, you get a CaseClauseError.
List Patterns
Lists pattern match on the head/tail structure.
iex> [head | tail] = [1, 2, 3, 4]
iex> head
1
iex> tail
[2, 3, 4]
You can grab multiple heads:
iex> [first, second | rest] = [1, 2, 3, 4]
iex> first
1
iex> second
2
iex> rest
[3, 4]
Match a fixed-length list:
iex> [a, b, c] = [1, 2, 3]
iex> [a, b, c] = [1, 2, 3, 4]
** (MatchError) no match of right hand side value: [1, 2, 3, 4]
Match an empty list:
iex> [] = []
[]
iex> [] = [1]
** (MatchError)
This is how recursive list functions terminate:
defmodule MyList do
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)
end
Two clauses, both pattern matches on the argument. The empty list returns 0; anything else destructures into head and tail and recurses. No if, no length check, no guard.
You can match specific values inside list patterns:
def starts_with_zero?([0 | _]), do: true
def starts_with_zero?(_), do: false
The underscore _ is a wildcard — it matches anything and doesn't bind. Use it for parts you don't care about. By convention, _ followed by a name (_name) also doesn't bind but documents what's there.
Tuple Patterns
Tuples match by position, with fixed arity.
iex> {:point, x, y} = {:point, 3, 4}
iex> x
3
iex> {:ok, _} = {:ok, "result"}
{:ok, "result"}
The "tagged tuple" pattern is everywhere in Elixir return values:
def fetch_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
case fetch_user(42) do
{:ok, user} -> render(user)
{:error, :not_found} -> render_404()
end
Tuples with the same shape and same first atom are conceptually "the same kind of thing" — {:ok, value} is success, {:error, reason} is failure. The first-element atom tags the variant.
Map Patterns
Maps match on the keys you specify. Other keys can exist in the map and are ignored.
iex> %{name: name} = %{name: "Ada", age: 36, role: :admin}
iex> name
"Ada"
iex> %{} = %{anything: "works"}
%{anything: "works"}
This "subset match" semantic is intentional. You ask for the keys you need, and you don't care about the rest. It's perfect for matching incoming params:
def create_user(%{"email" => email, "password" => password}) do
# email and password are bound; other params don't break the match
end
If you require all keys, list them all:
%{name: _, email: _, age: _} = user
But more often, you'd use a struct match for that, which pins the shape to a specific module. Maps are good for loose, dynamic shapes; structs for known domain types.
def greet(%User{name: name}), do: "Hello, #{name}"
Match on values inside a map:
def admin?(%{role: :admin}), do: true
def admin?(_), do: false
The :admin atom is a literal in the pattern, so the match only succeeds when role equals :admin.
Function Head Matching
This is where pattern matching turns into dispatch. You can define multiple clauses of the same function, and Elixir picks the first one that matches.
defmodule HttpStatus do
def describe(200), do: "OK"
def describe(201), do: "Created"
def describe(204), do: "No Content"
def describe(301), do: "Moved Permanently"
def describe(404), do: "Not Found"
def describe(500), do: "Internal Server Error"
def describe(code) when code >= 200 and code < 300, do: "Success (#{code})"
def describe(code) when code >= 400 and code < 500, do: "Client Error (#{code})"
def describe(code) when code >= 500, do: "Server Error (#{code})"
def describe(code), do: "Unknown (#{code})"
end
This is idiomatic Elixir. Instead of a giant case, you write multiple function heads. They're easier to read, easier to test, easier to extend (you add a new clause rather than editing a big switch).
The same approach works for tagged tuples:
defmodule Result do
def map({:ok, value}, fun), do: {:ok, fun.(value)}
def map({:error, _} = error, _fun), do: error
def flat_map({:ok, value}, fun), do: fun.(value)
def flat_map({:error, _} = error, _fun), do: error
end
Notice the {:error, _} = error pattern — it matches the shape and also binds the whole tuple to error. This is the "match and bind" trick. You can put = inside a pattern to capture both a part and the whole.
def process(%User{email: email} = user) do
Logger.info("processing user #{email}")
do_work(user)
end
Use this whenever you need both a sub-field and the original value.
Pattern Matching Is Core Elixir DNA
Once you've internalized this, you start seeing it everywhere:
- Function clauses dispatch by pattern.
case,cond,with, andtry/rescueare pattern-based.receiveblocks in processes pattern-match on incoming messages.- GenServer's
handle_call,handle_cast,handle_infoare functions with multiple pattern-matching heads. - Tagged tuples are the universal return convention because they pattern match cleanly.
- Phoenix routers, Plug pipelines, LiveView events — all built on pattern matching.
This is why "Elixir thinking" feels different from "Python thinking" or "Java thinking." In OO languages, you dispatch on type via methods. In Elixir, you dispatch on shape via patterns. The shape can include the type, but it can also include specific values, sub-structures, and arbitrary combinations. This is more flexible and, once you get used to it, more concise.
A Worked Example
Here's a function that classifies an HTTP response, written the way you'd actually write it:
defmodule HttpClient do
def handle_response({:ok, %{status: 200, body: body}}), do: {:ok, body}
def handle_response({:ok, %{status: 404}}), do: {:error, :not_found}
def handle_response({:ok, %{status: status}}) when status >= 500, do: {:error, :server_error}
def handle_response({:ok, %{status: status, body: body}}), do: {:error, {status, body}}
def handle_response({:error, %{reason: :timeout}}), do: {:error, :timeout}
def handle_response({:error, _} = err), do: err
end
Six clauses, each handling a specific case. No nested if, no case inside case, no flag variables. Every shape of input has its own clearly-named handler. This is the style.
Common Pitfalls
Forgetting that = matches, not assigns. 1 = x is a valid expression that asserts x equals 1. Useful for inline assertions; surprising the first time it raises.
Trying to match a variable against an existing value. existing_id = some_id rebinds existing_id to whatever some_id is. To match against the existing value, use the pin operator (next file).
Over-broad map matches. %{role: role} succeeds with any map that has a :role key, including ones with role: "user" (string) versus role: :user (atom). Be specific about value types when it matters.
Listing all keys in a map pattern when you only need a few. Map patterns are subset matches by design. %{name: name} works on a 50-field map. Don't list keys you don't use.
Order-dependent function clauses. Multiple heads are tried top to bottom. Specific clauses go first, general clauses (_ wildcards, broad guards) go last. Otherwise, the general clause swallows everything.
Confusing = with ==. = matches and binds; == compares for equality and returns boolean. if x = something do is sometimes valid (binds and tests truthiness), but usually a typo for ==.
Key Takeaways
=is the match operator, not assignment. The left side is a pattern; the right side is a value.- Lists destructure with
[head | tail]or fixed shapes like[a, b, c]. - Tuples match by exact arity and position.
- Maps match by subset — only specified keys must be present.
- Multiple function heads dispatch by pattern; first match wins, so order specific-to-general.
pattern = valueinside a clause both matches and binds the whole.- Tagged tuples (
{:ok, value}and{:error, reason}) are the universal return convention. - Pattern matching shows up at every level: clauses,
case,with,receive, GenServer callbacks, Phoenix routes.