Elixir & Erlang/OTP Module 3 – Pattern matching and control flow
case, cond, if and unless
Choose between case, cond, if and unless in Elixir: clauses and guards, what happens when nothing matches, if without else, and the 1.20 clause checks.
What you will learn
- Write case clauses with patterns and guards that branch on the shape of a value
- Choose cond for a chain of conditions and if for a single one
- Predict which constructs raise when nothing matches and which return nil
- Use the value a branch returns instead of binding a name inside it
- Read the warnings Elixir 1.20 gives about clauses that can never run
Before you start
What you’ll learn · Before you start
What you will learn
- Write case clauses with patterns and guards that branch on the shape of a value
- Choose cond for a chain of conditions and if for a single one
- Predict which constructs raise when nothing matches and which return nil
- Use the value a branch returns instead of binding a name inside it
- Read the warnings Elixir 1.20 gives about clauses that can never run
Before you start
On this page
A payment service answers in three shapes, {:ok, receipt}, {:error, :timeout} or {:error, reason}, and each
needs different code: show the receipt, try again, or say why the payment was refused. case tries patterns like
those of the previous lesson against the reply, from the top, and runs the first that fits; a reply that fits none
raises an error. Elixir has three more ways to branch, and they do not agree on what happens when nothing fits.
Below, you will branch on shapes with case and guard: A condition after when in a clause, as in {:ok, code} when code >= 400. Only a fixed set of expressions may appear in it, and an error inside a guard makes the clause fail instead of raising., chain conditions with cond, test
one condition with if, see which of them raise and which return nil, keep the value a branch returns, read what
Elixir 1.20 says about clause: One branch of a case, a cond or a function: a pattern or a condition, an optional guard, an arrow and the code to run. Clauses are tried from the top, and the first that fits runs. that can never run, and choose between them. unless gets its
own note: it still works, but it is deprecated. Each run below used Elixir 1.20.4 on top of Erlang/OTP 29.1.1; a
single comparison adds Elixir 1.19.6.
case runs the first clause that fits
Here is the payment service’s reply handled with case. Each clause is a pattern, an arrow and the code to run.
case works out its value once, then tries the clauses from the top:
# A payment service answers in one of these shapes. case runs the first clause whose pattern fits the reply.
defmodule Payment do
def message(reply) do
case reply do
{:ok, receipt} -> "paid, receipt #{receipt}"
{:error, :timeout} -> "no answer: try again"
{:error, reason} -> "refused: #{reason}"
end
end
end
IO.puts(Payment.message({:ok, "R-1042"}))
IO.puts(Payment.message({:error, :timeout}))
IO.puts(Payment.message({:error, :declined}))
IO.puts(Payment.message(:pending)) from session import run
# The last reply fits no clause, so the script stops there.
run("""
!elixir payment.exs
""") Output
$ elixir payment.exs
paid, receipt R-1042
no answer: try again
refused: declined
** (CaseClauseError) no case clause matching:
:pending
payment.exs:4: Payment.message/1
payment.exs:15: (file)
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 payment_session.py
{:ok, "R-1042"} fitted the first clause, and receipt was bound to “R-1042” for the code of that clause.
{:error, :timeout} skipped the first clause and fitted the second exactly. {:error, :declined} failed both and
fitted the third, which takes any error and binds reason. :pending fitted none, so case raised
CaseClauseError: The error a case raises when the value fits none of its clauses: no case clause matching, followed by the value. A cond whose conditions are all false or nil raises CondClauseError instead., “no case clause matching”, followed by the value; the stack trace
points at line 4, the case, and at line 15, the call. Step through how the replies travel down the clauses:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The value, once. case evaluates the expression after the word case once, here the reply {:error, :timeout}, and then tries its clauses against that value, from the top.
- Clause 1 does not fit. The first pattern, {:ok, receipt}, needs :ok in position 0. The reply holds :error there, so the clause does not fit, and its code does not run.
- Clause 2 fits. {:error, :timeout} fits exactly, so its code runs, and its value is the value of the whole case. The clauses below it are never tried.
- A declined payment. {:error, :declined} fails clause 1, and fails clause 2, whose atom is :timeout. Clause 3, {:error, reason}, fits any error and binds reason to :declined.
- No clause fits. :pending is not a tuple, so it fits no clause, and case raises CaseClauseError with the value, as the last line of payment.exs did.
- The order matters. Put clause 3 above clause 2 and a timeout fits {:error, reason} first, so "try again" never runs. Write the specific clauses before the general ones.
The whole case is an expression: its value is the value of the clause that ran, and message/1 returned it.
Names bound in a clause, such as receipt, exist only in that clause’s code. And the order of the clauses is part of
their meaning. Here are the same clauses with the last two swapped, compiled, then called with a timeout:
defmodule Payment do
# The clauses of payment.exs with the last two swapped: the general clause comes first.
def message(reply) do
case reply do
{:ok, receipt} -> "paid, receipt #{receipt}"
{:error, reason} -> "refused: #{reason}"
{:error, :timeout} -> "no answer: try again"
end
end
end from session import run
# The compiler warns about a clause that a clause above it always takes first; the call shows the timeout refused.
run("""
cd order
elixirc payment.ex
elixir -pa . -e "IO.puts(Payment.message({:error, :timeout}))"
""") Output
$ cd order
$ elixirc payment.ex
warning: this clause cannot match because a previous clause at line 6 matches the same pattern as this clause
│
7 │ {:error, :timeout} -> "no answer: try again"
│ ~
│
└─ payment.ex:7:26
warning: the following clause is redundant:
{:error, :timeout} ->
previous clauses have already matched on the following types:
{:ok, term()}
{:error, term()}
type warning found at:
│
7 │ {:error, :timeout} -> "no answer: try again"
│ ~
│
└─ payment.ex:7:26: Payment.message/1
$ elixir -pa . -e "IO.puts(Payment.message({:error, :timeout}))"
refused: timeout
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 clause_order_session.py
The compiler warned twice that the {:error, :timeout} clause can never match, because {:error, reason} above it
takes every error first, and the call proved it: the timeout was refused instead of retried.
Key idea
case works out its value once and runs the first clause whose pattern fits, so put specific clauses above general
ones; a value that fits no clause raises CaseClauseError.
Checkpoint
Check: Clauses of case, tried from the top
Guards add conditions, and an error in one only skips the clause
A pattern checks the shape of a value; a guard checks a condition on the parts the pattern bound. It follows when,
and the clause runs only if its pattern fits and its guard is true. HTTP status codes make a good case: they are all
integers, so their ranges decide. The second case below shows what happens when a guard itself fails:
from session import shell
# Guards add a condition to a clause. An error inside a guard does not raise: the clause just does not match.
shell([
"status = 404",
"case status do",
" code when code in 200..299 -> :success",
" code when code in 400..499 -> :client_error",
" code when code >= 500 -> :server_error",
"end",
"x = 1",
"case x do",
" y when hd(y) == 1 -> :list_starting_with_1",
" _ -> :something_else",
"end",
"hd(x)",
"System.halt()",
]) Output
$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]
Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> status = 404
404
iex(2)> case status do
iex(2)> code when code in 200..299 -> :success
iex(2)> code when code in 400..499 -> :client_error
iex(2)> code when code >= 500 -> :server_error
iex(2)> end
:client_error
iex(3)> x = 1
1
iex(4)> case x do
iex(4)> y when hd(y) == 1 -> :list_starting_with_1
iex(4)> _ -> :something_else
iex(4)> end
:something_else
iex(5)> hd(x)
** (ArgumentError) errors were found at the given arguments:
* 1st argument: not a nonempty list
:erlang.hd(1)
iex:5: (file)
iex(5)> System.halt()
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 guards_session.py
code when code in 400..499 took 404, because in with a range is allowed in a guard. In the second case, hd(y)
raised an error, since hd/1 takes only a non-empty list, as the last line shows on its own. Inside the guard, the
same error did not escape: the Elixir guide says that errors in guards “do not leak but simply make the guard fail”,
so the first clause did not match, and _ took the value. Step through it:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- The pattern fits. case tries clause 1. Its pattern is the name y, which fits any value, so y is bound to 1, and the guard after when decides.
- The guard raises. The guard calls hd(1). hd/1 raises ArgumentError for anything that is not a non-empty list, as hd(x) did on a line of its own.
- The guard just fails. An error inside a guard does not leave the guard. The guard counts as false, and clause 1 simply does not match, with no error shown.
- The next clause runs. case moves on to clause 2. _ fits anything, so the case returns :something_else, exactly as the session printed.
A guard that fails quietly is safe, but it can also hide a value you did not expect: the value moves on to the next
clause without a word, as 1 did here. Only a fixed set of expressions may appear in a guard, as the patterns guide
lists them: comparisons, the strict boolean operators and, or and not, arithmetic, in with a list or a range,
type checks such as is_integer/1, functions on built-in types such as hd/1 and map_size/1, and map.field. A
function of a module such as String is refused when the code compiles:
defmodule Name do
# Only a fixed set of expressions may appear in a guard, and String.length/1 is not one of them.
def check(name) do
case name do
n when String.length(n) > 20 -> :too_long
_ -> :ok
end
end
end from session import run
run("""
cd guard_call
!elixirc name.ex
""") Output
$ cd guard_call
$ elixirc name.ex
error: cannot invoke remote function String.length/1 inside a guard
│
5 │ n when String.length(n) > 20 -> :too_long
│ ^
│
└─ name.ex:5:21: Name.check/1
== Compilation error in file name.ex ==
** (CompileError) name.ex: cannot compile module Name (errors have been logged)
(stdlib 8.1) lists.erl:2673: :lists.mapfoldl_1/3
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 guard_call_session.py
The compiler stopped at line 5 with “cannot invoke remote function String.length/1 inside a guard”. Work such a value
out before the case, or test it in the code of a clause instead.
Checkpoint
Check: Guards, and errors inside them
Elixir 1.20 finds clauses that can never run
The compiler has long caught a clause hidden behind a broader pattern, as with the swapped payment clauses. Elixir 1.20’s type checker reads further: it works out types from patterns and guards, and it carries what one clause has ruled out into the clauses after it, which its changelog calls type inference across clauses and occurrence typing: Narrowing the type of a value inside each clause from what the clauses before it ruled out. Elixir 1.20 does it for case, cond and with: after a nil clause, the next clause knows the value is not nil.. Each of these two modules has a mistake:
defmodule Kind do
# The first clause takes every number, so the second can never run.
def of(x) do
case x do
n when is_number(n) -> :number
n when is_integer(n) and n > 0 -> :positive_integer
_ -> :other
end
end
end defmodule ServerPort do
# System.get_env/1 returns nil or a string. After the nil clause, value can only be a string.
def number do
case System.get_env("PORT") do
nil -> 4000
value -> value + 1
end
end
end from session import run, use
# Elixir 1.20.4 reads the clauses' guards and patterns while it compiles; then the same files on Elixir 1.19.6.
run("""
cd checks
elixirc -o new kind.ex
elixirc -o new server_port.ex
""")
use(erlang="28.5.0.7", elixir="1.19.6-otp-28")
run("""
elixir --version
elixirc -o old kind.ex
elixirc -o old server_port.ex
""") Output
$ cd checks
$ elixirc -o new kind.ex
warning: the following clause is redundant:
n when is_integer(n) and n > 0 ->
previous clauses have already matched on the following types:
float() or integer()
where "n" was given the types:
# type: integer()
# from: kind.ex:6:7
n
# type: integer()
# from: kind.ex:6:14
is_integer(n)
type warning found at:
│
6 │ n when is_integer(n) and n > 0 -> :positive_integer
│ ~
│
└─ kind.ex:6:38: Kind.of/1
$ elixirc -o new server_port.ex
warning: incompatible types given to Kernel.+/2:
value + 1
given types:
-binary()-, integer()
but expected one of:
#1
integer(), integer()
#2
integer(), float()
#3
float(), integer()
#4
float(), float()
where "value" was given the type:
# type: binary()
# from: server_port.ex:6:7
value
type warning found at:
│
6 │ value -> value + 1
│ ~
│
└─ server_port.ex:6:22: ServerPort.number/0
$ elixir --version
Erlang/OTP 28 [erts-16.4.0.6] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]
Elixir 1.19.6 (compiled with Erlang/OTP 28)
$ elixirc -o old kind.ex
$ elixirc -o old server_port.ex
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 checks_session.py
In kind.ex the first clause takes every number, so the second, for positive integers, can never run. Elixir 1.20.4
called it a redundant clause: A clause that can never run, because the clauses before it already match every value it could match. The compiler warns about one, and Elixir 1.20 also finds those that only types and guards reveal. and listed the types the first clause had already
matched, float() or integer(). In server_port.ex, System.get_env/1 returns nil or a string; once the nil
clause has taken nil, the next clause knows that value is a binary(), so value + 1 was reported before the
code ever ran. Elixir 1.19.6 compiled both files without a word.
The fixes are what the warnings point at: the specific clause first in kind.ex, and String.to_integer(value) in
server_port.ex. Treat these warnings as errors in waiting: each one names a clause or an operation that cannot
behave the way the code was written to behave.
Checkpoint
Check: Clauses that can never run
cond runs the first condition that is truthy
Some choices are not about shape at all. A courier sorts parcels into bands by weight, and each band is a comparison.
cond takes conditions instead of patterns and runs the first one that is truthy: Counting as true in a condition. In Elixir every value is truthy except false and nil, so 0, the empty string and the empty list are all truthy. More in “Atoms, booleans, nil and comparison”:
from session import shell
# cond runs the first condition that is truthy; with none, it raises.
shell([
"weight = 25",
"cond do",
" weight <= 2 -> :small",
" weight <= 10 -> :medium",
" weight <= 20 -> :large",
"end",
"cond do",
" weight <= 2 -> :small",
" weight <= 10 -> :medium",
" weight <= 20 -> :large",
" true -> :freight",
"end",
"cond do",
" nil -> :first",
" 0 -> :second",
"end",
"System.halt()",
]) Output
$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]
Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> weight = 25
25
iex(2)> cond do
iex(2)> weight <= 2 -> :small
iex(2)> weight <= 10 -> :medium
iex(2)> weight <= 20 -> :large
iex(2)> end
** (CondClauseError) no cond clause evaluated to a truthy value
iex:5: (file)
iex(2)> cond do
iex(2)> weight <= 2 -> :small
iex(2)> weight <= 10 -> :medium
iex(2)> weight <= 20 -> :large
iex(2)> true -> :freight
iex(2)> end
:freight
iex(3)> cond do
iex(3)> nil -> :first
iex(3)> 0 -> :second
iex(3)> end
:second
iex(4)> System.halt()
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cond_session.py
For a parcel of 25 kg no condition was true, so the first cond raised CondClauseError, “no cond clause evaluated
to a truthy value”. The guide’s advice is a final condition that is always true, a
catch-all clause: A last clause that every value fits, such as _ -> in a case or true -> in a cond. It turns a value that fits no other clause into a branch you wrote, instead of an error.: true -> :freight took every parcel the bands above it did not.
Step through the second cond:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Conditions, not patterns. cond has no value to match. It evaluates its conditions one by one, from the top, until one gives a truthy value, here for a parcel of 25 kg.
- Not small. 25 <= 2 is false, so the clause does not run, and cond moves on to the next condition.
- Not medium. 25 <= 10 is false as well.
- Not large. 25 <= 20 is false too. Without another clause, cond would run out here and raise CondClauseError, as the first cond of the session did.
- The catch-all. true is always truthy, so a last true -> clause takes every parcel the bands above did not: cond returns :freight.
The parcel stopped at the first truthy condition, so the order matters as it does in case: with weight <= 10
above weight <= 2, every small parcel would land in :medium. The last cond of the session shows what truthy
means: anything but false and nil. The nil condition was skipped, and 0 ran its clause, although 0 counts as
false in many other languages.
A cond that has no answer for some values
A cond without a final true -> raises CondClauseError for every value its conditions miss, such as a parcel
over 20 kg. End it with true -> and a real answer when every value must get one, as :freight is here.
Checkpoint
Check: Chains of conditions with cond
if tests one condition, and without else it gives nil
if is the simplest branch: one condition, a do block and an optional else block. The guide notes that it is a
macro, and like cond it counts every value but false and nil as true. The block form and the one-line form,
with the keywords do: and else:, mean the same:
from session import shell
# if tests one condition; without else it gives nil when the condition is false or nil.
shell([
"stock = 0",
"if stock > 0 do",
' "in stock"',
"else",
' "sold out"',
"end",
'label = if stock > 0, do: "in stock"',
"label",
'if "", do: :truthy, else: :falsy',
'unless stock > 0, do: "sold out"',
"System.halt()",
]) Output
$ iex Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit] Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help) iex(1)> stock = 0 0 iex(2)> if stock > 0 do iex(2)> "in stock" iex(2)> else iex(2)> "sold out" iex(2)> end "sold out" iex(3)> label = if stock > 0, do: "in stock" nil iex(4)> label nil iex(5)> if "", do: :truthy, else: :falsy :truthy iex(6)> unless stock > 0, do: "sold out" "sold out" iex(7)> System.halt()
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 if_session.py
With 0 in stock the block form returned “sold out”. The one-line form had no else, so it returned nil, as the
Kernel documentation says if does when its condition is false or nil, and label was nil: a value that string
interpolation turns into an empty string without a word, and that the next step of a program may stumble over. Give
every if whose value you use an else. The empty string is truthy, so if "" took its do: branch.
The last line used unless, which runs its block when the condition is false or nil. It still works on Elixir
1.20.4, but the Kernel documentation now says: “This macro is deprecated. Use if/2 instead.” Elixir 1.18 made it a
soft deprecation: Marking a feature as deprecated in its documentation and changelog while the compiler stays silent about it. Elixir 1.18 soft-deprecated unless in favour of if., listed in its changelog among the deprecations with no warnings
emitted, so the compiler stays silent about it for now. mix format --migrate rewrites every unless for you:
defmodule Stock do
def warning(stock) do
unless stock > 0 do
"sold out"
end
end
def greeting(name) do
unless name, do: "Hello, guest", else: "Hello, #{name}"
end
end from session import run
# mix format --migrate rewrites every unless as an if with the condition negated.
run("""
cd migrate
elixirc stock.ex
mix format --migrate stock.ex
cat stock.ex
""") Output
$ cd migrate
$ elixirc stock.ex
$ mix format --migrate stock.ex
$ cat stock.ex
defmodule Stock do
def warning(stock) do
if not (stock > 0) do
"sold out"
end
end
def greeting(name) do
if !name, do: "Hello, guest", else: "Hello, #{name}"
end
end
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 migrate_session.py
elixirc compiled the module without a warning. The formatter then wrote if not (stock > 0) for the comparison,
which is always a boolean, and if !name for name, which may be nil: not accepts only true and false, while
! accepts any value. The Mix documentation warns that the migration changes code and suggests running it in a
commit of its own and reading the result before you keep it.
Deprecated since Elixir 1.18: unless
unless has been soft-deprecated since Elixir 1.18, so it compiles without a warning on 1.20.4. Write new code with
if and the condition negated, and move old code with mix format --migrate.
Checkpoint
Check: if, else and the deprecated unless
Bind what a branch returns, not what it assigns
Every construct of this lesson is an expression: it returns the value of the branch that ran. That matters, because a name bound inside a branch stays inside it; the guide puts it as a change that “can’t leak to outer blocks”. This script was meant to take 10 off a price of 200 for premium customers:
# Meant to take 10 off a price of 200 for premium customers.
price = 200
premium = true
discount = 0
if premium do
discount = 10
end
IO.puts("charged #{price - discount}")
# The fix: bind what the if returns.
discount = if premium, do: 10, else: 0
IO.puts("charged #{price - discount}") from session import run
run("""
elixir scope.exs
""") Output
$ elixir scope.exs
warning: variable "discount" is unused (there is a variable with the same name in the context, use the pin operator (^) to match on it or prefix this variable with underscore if it is not meant to be used)
│
7 │ discount = 10
│ ~
│
└─ scope.exs:7:3
charged 200
charged 190
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 scope_session.py
The first charge was 200. discount = 10 inside the if bound a new discount that lived only in the do block,
and the compiler said so before the script ran: that variable is unused, and “there is a variable with the same name
in the context”. The outer discount stayed 0. The warning also suggests the pin, but ^discount = 10 would only
compare 0 with 10 and raise MatchError. What the script needs is the value of the if:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- discount = 0. discount = 0 binds discount, outside every block, to 0. price is 200 and premium is true.
- Inside the if. premium is true, so the if runs its do block. discount = 10 there binds a new discount that exists only in that block, which is why the compiler warned that it is unused.
- The block ends. When the block ends, its discount goes with it. The outer discount was never changed, so price - discount is 200 - 0, and the script charged 200.
- Bind what the if returns. The fix binds the outer name to the value of the if: if premium, do: 10, else: 0 returns 10, so the second charge was 200 - 10 = 190.
discount = if premium, do: 10, else: 0 binds the outer name to what the if returns, and the second charge was
190. The same holds for case and cond: bind the whole construct, and let each branch end with the value it stands
for.
Key idea
Bind the value a branch returns, as in discount = if premium, do: 10, else: 0; a name bound inside a branch is gone
when the branch ends.
Checkpoint
Check: Binding the value of a branch
Choose by what you are testing
The constructs overlap: a case on true and false can do what an if does, and cond can test what guards
test. Choose the one that says what decides the branch, and decide what should happen to a value that fits no
branch. Answer the two questions about the branch you are writing:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Construct | The shape of data | A chain of conditions | When nothing fits | In Elixir 1.20 |
|---|---|---|---|---|
| case value do … Decides by: patterns and guards, on one value | Good patterns and guards: {:ok, receipt}, [head | _], %{name: n} | Mixed possible with guards on clauses that match anything | Mixed raises CaseClauseError; add _ -> on purpose | Good current |
| cond do … Decides by: conditions, from the top | Poor conditions only: no patterns, no names bound | Good one condition a clause, the first truthy one wins | Mixed raises CondClauseError; end with true -> | Good current |
| if condition do … else … end Decides by: one condition | Poor one truthy test | Poor nested ifs grow hard to read | Mixed returns nil without else, a quiet trap | Good current |
| unless condition do … Decides by: one condition, the other way round | Poor one falsy test | Poor one test only | Mixed returns nil without else | Poor deprecated since Elixir 1.18: use if |
How the answers decide
- “What decides which branch runs?” The shape of one value, such as {:ok, x}: rules out cond do …, if condition do … else … end and unless condition do …. Only case can match a pattern, bind the parts it names and check them with guards.
- “What decides which branch runs?” Several separate conditions, tried in order: rules out if condition do … else … end and unless condition do … and adds a caveat to case value do …. A case on true or on a name with guards works, but cond says the intent: one condition a clause, tried in order. Nested ifs hide the order.
- “What decides which branch runs?” A single yes-or-no condition: adds a caveat to case value do … and cond do …. They work, but one condition with two outcomes reads most plainly as if … else.
- “What decides which branch runs?” A single yes-or-no condition: rules out unless condition do …. unless is deprecated since Elixir 1.18: write if with the condition negated, as mix format --migrate does.
- “Can a value that fits none of your branches turn up in normal use?” Yes, and it needs an answer of its own: adds a caveat to case value do …, cond do … and if condition do … else … end. Give that value its branch: a catch-all clause, _ -> or true ->, or the else of an if, with code that handles it.
- “Can a value that fits none of your branches turn up in normal use?” No, it would mean a bug: adds a caveat to if condition do … else … end. Leave the catch-all out, so CaseClauseError or CondClauseError stops on a value that should never come. An if without else would hand on nil instead.
In short: case when the shape of one value decides, with guards for conditions on its parts; cond for a chain of
separate conditions, ending with true -> when every value must get an answer; if with an else for a single
condition; and if with the condition negated where older code says unless. Leave the catch-all out when a value
that fits nothing would mean a bug, so that the error stops it. When branching is a function’s whole job, several
clauses of the function itself do the work of one case, as the next module shows.
Checkpoint
Check: Choosing case, cond or if
Practise: letters for marks
Write Grade.letter/1, which turns a percentage, an integer or a float, into a letter with cond: {:ok, "A"} for
90 and above, “B” from 75, “C” from 60, “D” from 40 and “F” below that, or {:error, :out_of_range} below 0 or above
100. Then write Grade.describe/1 with case: a mark is {:ok, percent} or {:error, reason}, and it becomes a line
such as “92: A”, “120: out of range” or “no mark (absent)”. Copy the two files into an empty folder:
defmodule Grade do
@moduledoc "Letters for percentages, and a line about a student's mark."
@doc """
The letter for a percentage from 0 to 100, an integer or a float: {:ok, "A"} for 90 and above,
"B" from 75, "C" from 60, "D" from 40 and "F" below 40; {:error, :out_of_range} below 0 or above 100.
"""
def letter(_percent) do
# Drop the underscore once you use the argument.
:todo
end
@doc """
A line about a mark, which is {:ok, percent} or {:error, reason}: "92: A",
"120: out of range" or "no mark (absent)".
"""
def describe(_mark) do
:todo
end
end Code.require_file("grade.ex", __DIR__)
ExUnit.start(seed: 0)
defmodule GradeTest do
use ExUnit.Case
test "90 and above is an A" do
assert Grade.letter(90) == {:ok, "A"}
assert Grade.letter(100) == {:ok, "A"}
end
test "just below a boundary takes the lower letter" do
assert Grade.letter(89.5) == {:ok, "B"}
assert Grade.letter(39) == {:ok, "F"}
end
test "each band has its letter" do
assert Grade.letter(75) == {:ok, "B"}
assert Grade.letter(60) == {:ok, "C"}
assert Grade.letter(40.0) == {:ok, "D"}
assert Grade.letter(0) == {:ok, "F"}
end
test "a percentage outside 0 to 100 is an error, not a letter" do
assert Grade.letter(101) == {:error, :out_of_range}
assert Grade.letter(-1) == {:error, :out_of_range}
end
test "describe gives the letter of a mark" do
assert Grade.describe({:ok, 92}) == "92: A"
assert Grade.describe({:ok, 59.5}) == "59.5: D"
end
test "describe says when a mark is out of range" do
assert Grade.describe({:ok, 120}) == "120: out of range"
end
test "describe gives the reason when there is no mark" do
assert Grade.describe({:error, :absent}) == "no mark (absent)"
end
end Run elixir grade_test.exs. The starter returns :todo, so all seven tests fail:
from session import run
# The tests against the starter, which changes nothing.
run("""
cd practice
!elixir grade_test.exs
""") Output
$ cd practice
$ elixir grade_test.exs
Running ExUnit with seed: 0, max_cases: 36
1) test 90 and above is an A (GradeTest)
grade_test.exs:7
Assertion with == failed
code: assert Grade.letter(90) == {:ok, "A"}
left: :todo
right: {:ok, "A"}
stacktrace:
grade_test.exs:8: (test)
2) test just below a boundary takes the lower letter (GradeTest)
grade_test.exs:12
Assertion with == failed
code: assert Grade.letter(89.5) == {:ok, "B"}
left: :todo
right: {:ok, "B"}
stacktrace:
grade_test.exs:13: (test)
3) test each band has its letter (GradeTest)
grade_test.exs:17
Assertion with == failed
code: assert Grade.letter(75) == {:ok, "B"}
left: :todo
right: {:ok, "B"}
stacktrace:
grade_test.exs:18: (test)
4) test a percentage outside 0 to 100 is an error, not a letter (GradeTest)
grade_test.exs:24
Assertion with == failed
code: assert Grade.letter(101) == {:error, :out_of_range}
left: :todo
right: {:error, :out_of_range}
stacktrace:
grade_test.exs:25: (test)
5) test describe gives the letter of a mark (GradeTest)
grade_test.exs:29
Assertion with == failed
code: assert Grade.describe({:ok, 92}) == "92: A"
left: :todo
right: "92: A"
stacktrace:
grade_test.exs:30: (test)
6) test describe says when a mark is out of range (GradeTest)
grade_test.exs:34
Assertion with == failed
code: assert Grade.describe({:ok, 120}) == "120: out of range"
left: :todo
right: "120: out of range"
stacktrace:
grade_test.exs:35: (test)
7) test describe gives the reason when there is no mark (GradeTest)
grade_test.exs:38
Assertion with == failed
code: assert Grade.describe({:error, :absent}) == "no mark (absent)"
left: :todo
right: "no mark (absent)"
stacktrace:
grade_test.exs:39: (test)
Finished in 0.03 seconds (0.03s on load, 0.00s async, 0.00s sync)
Result: 0/7 passed
Failed: 7 tests
[exit status 2]
This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
For letter/1, put the range check first, percent < 0 or percent > 100, then the bands from the top down, and end
with true -> for “F”, so every percentage in range gets a letter. For describe/1, let one clause take
{:ok, percent} and ask letter/1, branching on its answer, and another take {:error, reason}. Keeping the range
rule in letter/1 alone means it is written once. A finished version passes all seven tests:
from session import run
# The same tests against finished letter/1 and describe/1.
run("""
cd finished
cp ../practice/grade_test.exs .
elixir grade_test.exs
""") Output
$ cd finished $ cp ../practice/grade_test.exs . $ elixir grade_test.exs Running ExUnit with seed: 0, max_cases: 36 ....... Finished in 0.02 seconds (0.02s on load, 0.00s async, 0.00s sync) Result: 7 passed
This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 finished_session.py
Now the self-check of this lesson. It starts where your answers are weakest, moves between the seven ideas above, and explains every answer the moment you give it:
Practice that adapts to you
Practise this lesson
20 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: when do you use case, when cond and when if in Elixir?
Use case when the shape of one value decides: it matches the value against patterns from the top and runs the
first clause that fits, so {:ok, receipt} and {:error, reason} get different code, and guards add conditions on
the parts a pattern bound, such as code when code in 400..499. Use cond for a chain of separate conditions that
are not about one value’s shape, such as weight bands; it runs the first condition that is truthy. Use if for a
single condition, with an else whenever you use its value, since without one it returns nil. Know what each does
when nothing fits: case raises CaseClauseError and cond raises CondClauseError, so add _ -> or true ->
only for values you expect. unless is deprecated since Elixir 1.18; write if with the condition negated.
Warm-up: what is a guard in Elixir, and what may it contain?
A guard is a condition after when in a clause, as in {:ok, code} when code >= 400. The clause runs only if its
pattern fits and its guard is true, and the guard can use the names the pattern bound. Only a fixed set of
expressions may appear in it: comparisons, the strict and, or and not, arithmetic, in with a list or a
range, type checks such as is_integer/1, functions on built-in types such as hd/1 and map_size/1, and
map.field. &&, || and ! are refused because they accept any value, and so is a call such as
String.length/1. An error inside a guard does not escape: the guard fails, and the next clause is tried, so a
value of the wrong kind can slip past without a word.
Two questions from code that branches on data it receives:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
An order router has the clauses {:ok, order} when length(order.items) > 0 -> ship(order) and {:ok, _order} -> :empty_order. Orders from an old import, whose items field is nil, have been reported as empty for weeks, and nothing ever raised. Explain why, and rewrite the clauses.
-
Fresher to mid
An HTTP client returns {:ok, %{status: status}} or {:error, reason}. Write the case that decides whether to retry: which statuses and errors are worth another try, in what order the clauses go, and what happens to a status or a reason you did not expect.
Key takeaways
caseworks out its value once and runs the first clause whose pattern fits, so specific clauses go above general ones, and a value that fits no clause raisesCaseClauseError.- A guard adds a condition after
when; only a fixed set of expressions may appear in it, and an error inside a guard makes the clause fail instead of raising. - Elixir 1.20 reports clauses that can never run, from patterns, guards and types, and narrows a value’s type from the clauses before it, where Elixir 1.19 said nothing.
condruns the first truthy condition and raisesCondClauseErrorwhen none is, so end it withtrue ->when every value needs an answer.ifreturnsnilwithout anelse, andunlessis deprecated since Elixir 1.18 in favour ofifwith the condition negated, whichmix format --migratewrites for you.- Every branch is an expression: bind the value it returns, because a name bound inside a branch is gone when it ends.
Terms in this lesson
- CaseClauseError
- The error a case raises when the value fits none of its clauses: no case clause matching, followed by the value. A cond whose conditions are all false or nil raises CondClauseError instead.
- catch-all clause
- A last clause that every value fits, such as _ -> in a case or true -> in a cond. It turns a value that fits no other clause into a branch you wrote, instead of an error.
- clause
- One branch of a case, a cond or a function: a pattern or a condition, an optional guard, an arrow and the code to run. Clauses are tried from the top, and the first that fits runs.
- guard
- A condition after when in a clause, as in {:ok, code} when code >= 400. Only a fixed set of expressions may appear in it, and an error inside a guard makes the clause fail instead of raising.
- occurrence typing
- Narrowing the type of a value inside each clause from what the clauses before it ruled out. Elixir 1.20 does it for case, cond and with: after a nil clause, the next clause knows the value is not nil.
- redundant clause
- A clause that can never run, because the clauses before it already match every value it could match. The compiler warns about one, and Elixir 1.20 also finds those that only types and guards reveal.
- soft deprecation
- Marking a feature as deprecated in its documentation and changelog while the compiler stays silent about it. Elixir 1.18 soft-deprecated unless in favour of if.
References
- Elixir guide: case, cond, and if (The Elixir Team (hexdocs.pm))
- Patterns and guards (Elixir reference) (The Elixir Team (hexdocs.pm))
- Kernel.SpecialForms (Elixir): case/2 and cond/1 (The Elixir Team (hexdocs.pm))
- Kernel (Elixir): if/2 and unless/2 (The Elixir Team (hexdocs.pm))
- CaseClauseError (Elixir) (The Elixir Team (hexdocs.pm))
- CondClauseError (Elixir) (The Elixir Team (hexdocs.pm))
- Changelog for Elixir v1.20: typing across clauses and redundant clauses (The Elixir Team (hexdocs.pm))
- Gradual set-theoretic types (Elixir) (The Elixir Team (hexdocs.pm))
- Changelog for Elixir v1.18: soft deprecation of unless (The Elixir Team (GitHub))
- Code (Elixir): the :migrate_unless formatter option (The Elixir Team (hexdocs.pm))
- mix format (Mix): the --migrate option (The Elixir Team (hexdocs.pm))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress