Before we can talk about if, case, or any kind of looping, we need to get comfortable with the most fundamental building block of Elixir’s control flow: pattern matching. In a language that treats data as immutable values, the only way to “look inside” a value is by matching its shape against a pattern. The = operator is not an assignment; it is a match operator that attempts to bind variables on the left‑hand side to the corresponding parts of the right‑hand side.
Basic Match: Variables and Literals
A lone variable matches anything:
value = 42 # binds value to 42
value = {:ok, "ok"} # now value is the tuple
Literal values can also appear on the left side. When a literal is present, the match only succeeds if the right‑hand side contains the same literal:
:ok = :ok # succeeds, returns :ok
:ok = :error # raises MatchError
This property becomes handy when you want to “confirm” that a function returned a particular tag, as we’ll see later.
Destructuring Tuples
Tuples are the most common way to bundle a fixed number of values together. By placing a pattern that mirrors the tuple’s shape on the left side, you can extract the pieces instantly:
{:user, name, age} = {:user, "Ada", 30}
# name => "Ada"
# age => 30
If the shape does not match, a MatchError is raised:
{:user, _, _} = {:order, 123}
# ** (MatchError) no match of right hand side value: {:order, 123}
Lists and the Head‑Tail Pattern
Lists are recursively defined as a head element followed by a tail list. The syntax [head | tail] lets you pull those parts apart:
[head | tail] = [10, 20, 30]
# head => 10
# tail => [20, 30]
You can also ignore a piece you don’t care about using the anonymous variable _:
[_ | rest] = [:a, :b, :c]
# rest => [:b, :c]
Maps: Pulling Out Named Fields
Maps are a natural place to store key‑value data. When you write a map pattern, you only need to mention the keys you care about; everything else is ignored:
%{email: email} = %{name: "Bob", email: "bob@example.com", age: 42}
# email => "bob@example.com"
If a required key is missing, the match fails:
%{email: _} = %{name: "Bob"}
# ** (MatchError) no match of right hand side value: %{name: "Bob"}
Pin Operator (^) – Matching Against an Existing Value
When a variable already holds a value, placing ^ in front of it forces the match to compare against that existing value rather than re‑binding it.
expected = :admin
{:role, ^expected} = {:role, :admin}
# matches
{:role, ^expected} = {:role, :guest}
# ** (MatchError) no match of right hand side value: {:role, :guest}
Use ^ when you need to enforce a previously computed or configured constant inside a pattern.
Binary Matching – Extracting Bytes
Strings in Elixir are binary data. Binary pattern matching makes it possible to split a binary into parts:
binary = <<1, 2, 3, 4>>
<> = binary
# first => 1
# rest => <<2, 3, 4>>
Bit‑level matching is also supported, but it is beyond the scope of this introductory article.
Chaining Matches
Since a match expression evaluates to the right‑hand side value, you can chain several matches together:
{:ok, data} = {:ok, "payload"}
[data, checksum] = String.split(data, "-")
# data => ["payload"]
# checksum => nil (no second part)
The key takeaway is that a match both asserts a shape and binds parts of the data to variables.
When you define a function, the argument list itself is a pattern. Elixir evaluates the list of clauses in order, picking the first one whose arguments match the call.
Single‑Clause Example: Computing a Rectangle’s Area
defmodule Geometry do
# expects a 2‑tuple {width, height}
def area({w, h}) do
w * h
end
end
Geometry.area({5, 3})
# => 15
If you call the function with something that isn’t a two‑element tuple, you’ll get a FunctionClauseError:
Geometry.area(42)
# ** (FunctionClauseError) no function clause matching in Geometry.area/1
Multiclause Functions – Dispatch by Shape
Suppose we want to compute areas for many geometric shapes, identified by a tag in the first tuple element. We can write a set of clauses, one for each shape:
defmodule Shapes do
# rectangle: {:rect, width, height}
def area({:rect, w, h}) do
w * h
end
# square: {:square, side}
def area({:square, s}) do
s * s
end
# circle: {:circle, radius}
def area({:circle, r}) do
:math.pi() * r * r
end
# fallback clause that catches any unknown shape
def area(other) do
{:error, {:unknown_shape, other}}
end
end
Shapes.area({:rect, 4, 5}) # => 20
Shapes.area({:square, 3}) # => 9
Shapes.area({:circle, 2}) # => 12.566370614359172
Shapes.area({:triangle, 3, 4}) # => {:error, {:unknown_shape, {:triangle, 3, 4}}}
Notice the order: the “catch‑all” clause is placed last, because Elixir checks clauses from top to bottom and stops at the first match.
Guards – Adding Extra Conditions
Sometimes the shape of the data alone isn’t enough. Guard clauses let you stipulate extra boolean constraints using a limited set of functions and operators.
defmodule Score do
# Only accept non‑negative integers.
def classify(n) when is_integer(n) and n >= 0 and n < 50, do: :low
def classify(n) when is_integer(n) and n >= 50 and n < 80, do: :medium
def classify(n) when is_integer(n) and n >= 80, do: :high
end
Score.classify(27) # => :low
Score.classify(73) # => :medium
Score.classify(92) # => :high
Score.classify(-5) # ** (FunctionClauseError)
Score.classify(:bad) # ** (FunctionClauseError)
Guards protect against nonsensical inputs (negative numbers or atoms) that would otherwise match the pattern n and lead to surprising results.
Anonymous Functions (Lambdas) with Multiple Clauses
Just as named functions can have many clauses, so can the fn … end construct. This is especially useful when passing a function as an argument to higher‑order APIs like Enum.map/2 or Enum.reduce/3.
# A lambda that classifies an order amount
order_classifier = fn
amount when amount >= 500 -> :premium
amount when amount >= 100 -> :standard
_ -> :basic
end
order_classifier.(750) # => :premium
order_classifier.(250) # => :standard
order_classifier.(30) # => :basic
Note that the fn syntax does not require parentheses around each clause; the arrow (->) separates a pattern (optionally with a guard) from its body.
Classic Conditional Constructs
Pattern matching and multiclause functions cover most branching needs, but sometimes a quick if or case expresses the intent more directly. All of these constructs are macros that translate into ordinary function calls under the hood.
if / unless
defmodule Temperature do
# Returns :freezing, :cold, :warm or :hot.
def describe(celsius) do
if celsius <= 0 do
:freezing
else
if celsius < 15 do
:cold
else
if celsius < 25 do
:warm
else
:hot
end
end
end
end
end
Temperature.describe(-5) # => :freezing
Temperature.describe(10) # => :cold
Temperature.describe(20) # => :warm
Temperature.describe(30) # => :hot
The unless macro is simply a negated if:
defmodule FeatureToggle do
def enabled?(flag) do
unless flag == :off, do: true, else: false
end
end
FeatureToggle.enabled?(:on) # => true
FeatureToggle.enabled?(:off) # => false
cond – A chain of boolean tests
The cond macro resembles “else‑if” ladders found in other languages. Each clause is a guard expression followed by an arrow.
defmodule Grade do
def letter(score) do
cond do
score >= 90 -> :A
score >= 80 -> :B
score >= 70 -> :C
true -> :F # default branch
end
end
end
Grade.letter(85) # => :B
Grade.letter(61) # => :F
The final true -> clause acts like an “else” block guaranteeing that one branch always matches.
case – Pattern‑matching on an arbitrary expression
Unlike cond, case uses pattern matching instead of boolean expressions. This is useful when you want to branch based on the shape of a value.
defmodule ResponseParser do
def status_code(response) do
case response do
{:ok, %{status: code}} -> code
{:error, reason} -> {:failed, reason}
end
end
end
ResponseParser.status_code({:ok, %{status: 200}})
# => 200
ResponseParser.status_code({:error, :timeout})
# => {:failed, :timeout}
Because patterns can be arbitrarily nested, case can express very sophisticated branching logic without any guard at all.
Putting It All Together: Recursive Loops via Pattern Matching
Elixir does not have a while or for loop in the traditional sense. Instead, iteration is typically expressed through recursion, where the base case and the recursive step are represented by separate function clauses.
Summing a List (Classic Recursive Loop)
defmodule Math do
# Base case: empty list sums to 0
def sum([]), do: 0
# Recursive case: head + sum(tail)
def sum([head | tail]), do: head + sum(tail)
end
Math.sum([1, 2, 3, 4]) # => 10
Math.sum([]) # => 0
Notice how the pattern [] matches the termination condition whereas [head | tail] handles the non‑empty case. The recursion automatically terminates when the list becomes empty.
Tail‑Recursive Factorial (Avoiding Stack Overflow)
Tail recursion passes an accumulator that holds the intermediate result. The final call returns the accumulator directly, allowing the BEAM VM to reuse the same stack frame.
defmodule Factorial do
def of(n) when n >= 0, do: do_of(n, 1)
# Tail‑recursive worker
defp do_of(0, acc), do: acc
defp do_of(n, acc) when n > 0, do: do_of(n - 1, n * acc)
end
Factorial.of(5) # => 120
Generating an Infinite Stream with Enum.unfold/2
While explicit loops are unnecessary for most tasks, sometimes you need a lazy, potentially infinite, sequence. The Enum.unfold/2 function creates a stream by repeatedly applying a function that returns {value, next_state} tuples. Pattern matching decodes the return value.
# Generate the Fibonacci sequence lazily.
fib_stream = Enum.unfold({0, 1}, fn {a, b} ->
{a, {b, a + b}}
end)
Enum.take(fib_stream, 10)
# => [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
The anonymous function receives a tuple {a, b} and returns a tuple where the first element is emitted as the next value and the second element becomes the new state for the following iteration.
Common Patterns and Practical Tips
- Prefer pattern‑matching arguments over
ifwhen you can. Functions that dispatch on shape make code self‑documenting and less error‑prone. - Guard clauses should be kept simple. Only use the functions listed in the official guard documentation. Complex logic belongs in the function body, not in the guard.
- Put the most specific clauses first. Because Elixir walks the clause list top‑to‑bottom, a broad “catch‑all” clause placed early would shadow more specific ones.
- Use the anonymous variable
_to discard values you don’t need. It prevents accidental “unused variable” warnings while still satisfying the pattern. - When you need to enforce a constant value inside a pattern, use the pin operator. Remember that
^varonly works ifvaris already bound. - Leverage binary matching for protocol parsers. Extract headers, footers, or delimiters without needing to call string functions repeatedly.
Typical Pitfalls
- Assuming
=reassigns a variable. Once a variable is bound, re‑binding it without^causes aMatchError. - Overusing guards to perform heavy computation. Guards are evaluated on every call; expensive operations should be placed in the function body.
- Forgetting the “default” clause order. Placing the ‘catch‑all’ clause earlier will make the more specific clauses unreachable.
- Using
casewherecondwould be clearer.caseexpects pattern matching; if you only need boolean checks,condis more idiomatic. - Mixing pattern matching with side effects in one clause. The clause should focus on matching; side effects (IO, logging) belong after the match succeeds.
Summary – The Core Takeaways
- Pattern matching is the heart of Elixir’s control flow. The
=operator matches a pattern on the left against a value on the right and binds variables. - Tuples, lists, maps, and binaries can all be destructured using patterns, optionally with the pin (
^) operator or anonymous variable (_). - Function arguments are patterns; multiple clauses let you write clean, declarative branching without explicit
ifstatements. - Guards extend pattern matching with boolean predicates but are limited to a predefined set of functions and operators.
- Anonymous functions (
fn … end) can also have multiple clauses, making them first‑class pattern‑matching constructs. - Classic conditionals (
if,unless,cond,case) are still available when they improve readability, especially for simple Boolean logic. - Recursion replaces traditional loops; base cases and recursive steps are expressed as separate pattern‑matching clauses.
- Write the most specific clauses first, use
_to ignore unneeded data, and employ the pin operator when you need to match against a previously bound value.
Armed with these tools, you can express any branching or looping logic in Elixir without resorting to mutable state or explicit loop constructs. The code reads like a series of declarative facts about the data you’re handling, which is exactly what makes functional programming both powerful and elegant.