OCaml Module 4 – Tuples, records, variants and pattern matching
Pattern matching in depth
Combine or-patterns, as-patterns and guards in OCaml, read warnings 8, 11 and 4, see why guards weaken the exhaustiveness check, and match on exceptions.
What you will learn
- Combine nested, or-, as- and guarded patterns in one match, written with function or match
- Recognise when guards weaken the exhaustiveness check, and rewrite the match so it passes
- Interpret warnings 8 and 11, and repair a fragile catch-all case that warning 4 reports
- Use match with exception and explain how it differs from try with
Before you start
What you’ll learn · Before you start
What you will learn
- Combine nested, or-, as- and guarded patterns in one match, written with function or match
- Recognise when guards weaken the exhaustiveness check, and rewrite the match so it passes
- Interpret warnings 8 and 11, and repair a fragile catch-all case that warning 4 reports
- Use match with exception and explain how it differs from try with
Before you start
On this page
A match on an integer with the cases 0, n when n > 0 and n when n < 0 covers every number, yet OCaml warns
that it is not exhaustive and offers 1 as a value it may miss. The checker reads patterns, not guards, and knowing what
it reads is the difference between a warning you can fix and one you learn to ignore.
You will combine or-patterns, as-patterns and guards, rewrite guarded matches so the checker can prove them complete, read warnings 8, 11 and 4, and handle an exception from the value being matched without hiding others.
Or-patterns, as-patterns and guards combine in one case
A pattern can do more than name a constructor. An or-pattern: A pattern p1 | p2 that matches when either side matches; both sides must bind the same variables., p1 | p2, matches when
either side does, so (0, _) | (_, 0) catches a zero in either place. An as-pattern, p as name, matches p and also
binds the whole matched value to name. A guard: A condition after a pattern, written when expr, that is tested only once the pattern has matched; if it is false, matching goes on to the next case., when condition, adds a test that runs only
after the pattern has matched. Cases are tried from the top, and the first one whose pattern and guard both succeed
wins. Step through the session’s classify on two of its inputs:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- classify (4, 4). The value (4, 4) is tried against the first case. Neither side is 0, so the or-pattern fails and the next case is tried.
- Pattern, then guard. (a, b) as p matches any pair, binding a = 4, b = 4 and p to the whole pair. Only then is the guard a = b tested, and it is true.
- The first full match wins. The second case's result is used and the later cases are never looked at. The session printed "(4, 4) is a double".
- classify (-1, 3). A new call starts again from the top. (-1, 3) has no zero, so the first case fails.
- Two guards fail. The pattern of case 2 matches but a = b is false; case 3 matches the pattern too, but a > 0 is false. A failed guard sends matching on to the next case.
- The catch-all. _ matches anything, so the last case gives "other". Without it, this value would reach no case and raise Match_failure.
from session import toplevel, write
# Or-patterns, as-patterns and guards combine in one match. Cases are tried from the top; a guard (when …) is tested
# only after its pattern has matched. function is fun plus match on the argument.
write("classify.ml", """let classify = function
| (0, _) | (_, 0) -> "has a zero"
| (a, b) as p when a = b -> Printf.sprintf "(%d, %d) is a double" (fst p) (snd p)
| (a, b) when a > 0 && b > 0 -> "both positive"
| _ -> "other"
let first_two = function
| x :: (y :: _ as rest) -> Printf.sprintf "%d then %d; tail %s" x y (String.concat "," (List.map string_of_int rest))
| [x] -> Printf.sprintf "only %d" x
| [] -> "empty"
""")
toplevel("""
#use "classify.ml";;
List.map classify [(0, 5); (4, 4); (2, 9); (-1, 3)];;
List.map first_two [[7; 8; 9]; [7]; []];;
""") Output
$ ocaml OCaml version 5.5.1 Enter #help;; for help. # #use "classify.ml";; val classify : int * int -> string = <fun> val first_two : int list -> string = <fun> # List.map classify [(0, 5); (4, 4); (2, 9); (-1, 3)];; - : string list = ["has a zero"; "(4, 4) is a double"; "both positive"; "other"] # List.map first_two [[7; 8; 9]; [7]; []];; - : string list = ["7 then 8; tail 8,9"; "only 7"; "empty"]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 classify_session.py
classify returned "has a zero" for (0, 5), "(4, 4) is a double" for (4, 4), "both positive" for (2, 9)
and "other" for (-1, 3). Patterns also nest: in first_two, x :: (y :: _ as rest) takes the first element as
x and binds the rest of the list, which must start with some y, to rest; for [7; 8; 9] it printed 7 then 8; tail 8,9. Both functions are written with function, which is fun x -> match x with in one word: the cases match
the function’s single argument.
| Pattern | Matches | Binds |
|---|---|---|
(0, _) or (_, 0), as one or-pattern |
a pair with a zero on either side | nothing |
(a, b) as p |
any pair | a, b and the whole pair p |
(a, b) when a = b |
a pair whose sides are equal | a, b |
x :: (y :: _ as rest) |
a list of two or more | x, y, and rest from y on |
An or-pattern that binds different names
Wrong: | (0, b) | (a, 0) -> …, which OCaml refuses because each side of an or-pattern must bind the same
variables. Right: | (0, _) | (_, 0) -> …, or two separate cases when each needs its own variable.
Guards are invisible to the exhaustiveness check
The compiler proves a match complete from its patterns alone. A guard is an arbitrary expression, so the checker treats every guarded case as one that may fail, and a match whose guards happen to cover everything still looks incomplete to it:
from session import toplevel
# Guards are not used by the exhaustiveness check, so a match whose guards cover every int still draws warning 8.
# Warning 11 marks a case that can never be reached. A refutation case, -> ., states that a case is impossible.
toplevel("""
let sign n = match n with 0 -> "zero" | n when n > 0 -> "positive" | n when n < 0 -> "negative";;
let sign n = match n with 0 -> "zero" | n when n > 0 -> "positive" | _ -> "negative";;
List.map sign [0; 12; -3];;
let f x = match x with _ -> 0 | 1 -> 1;;
let g = function Some 0 -> "zero" | Some _ -> "some" | None -> "none" | Some 1 -> "one";;
type empty = |;;
let only_none (x : empty option) = match x with None -> 0 | Some _ -> .;;
""") Output
$ ocaml
OCaml version 5.5.1
Enter #help;; for help.
# let sign n = match n with 0 -> "zero" | n when n > 0 -> "positive" | n when n < 0 -> "negative";;
Line 1, characters 13-95:
1 | let sign n = match n with 0 -> "zero" | n when n > 0 -> "positive" | n when n < 0 -> "negative";;
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Warning 8 [partial-match]: this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
1
(However, some guarded clause may match this value.)
val sign : int -> string = <fun>
# let sign n = match n with 0 -> "zero" | n when n > 0 -> "positive" | _ -> "negative";;
val sign : int -> string = <fun>
# List.map sign [0; 12; -3];;
- : string list = ["zero"; "positive"; "negative"]
# let f x = match x with _ -> 0 | 1 -> 1;;
Line 1, characters 32-33:
1 | let f x = match x with _ -> 0 | 1 -> 1;;
^
Warning 11 [redundant-case]: this match case is unused.
val f : int -> int = <fun>
# let g = function Some 0 -> "zero" | Some _ -> "some" | None -> "none" | Some 1 -> "one";;
Line 1, characters 72-78:
1 | let g = function Some 0 -> "zero" | Some _ -> "some" | None -> "none" | Some 1 -> "one";;
^^^^^^
Warning 11 [redundant-case]: this match case is unused.
val g : int option -> string = <fun>
# type empty = |;;
type empty = |
# let only_none (x : empty option) = match x with None -> 0 | Some _ -> .;;
val only_none : empty option -> int = <fun>
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 warnings_session.py
The first sign drew “Warning 8 [partial-match]”, with 1 as an example of an unmatched value and the note “(However,
some guarded clause may match this value.)”. The note is the compiler admitting that a guard might cover it, which it
cannot check. The fix is to make the last case unguarded: in the second sign, _ -> "negative" handles whatever
the guards did not, and the warning disappeared; List.map sign [0; 12; -3] gave ["zero"; "positive"; "negative"].
The same session shows two more messages. match x with _ -> 0 | 1 -> 1 drew “Warning 11 [redundant-case]: this match
case is unused”: _ already took every value, so the case after it can never run. In g, Some 1 came after
Some _, which had already matched it. The last line uses a refutation case, Some _ -> ., for a type with no
values, empty: it tells the checker that a case is impossible, and the checker agreed. The manual’s chapter on
GADTs is where this becomes common.
Key idea
The checker proves exhaustiveness from patterns alone: end a guarded match with an unguarded case, and read warning 8’s guarded-clause note as a request to restructure, not as a false alarm.
Checkpoint
Check: Or-, as- and guarded patterns · Guards and exhaustiveness
Warnings 8, 11 and 4 each point at a different fragility
Warning 8 means a value can reach no case and would raise Match_failure. Warning 11 means a case can never be
reached, usually because a broader case above it already matches. A third warning protects against the future. A
catch-all _ keeps a match exhaustive whatever happens to its type, so when the type gains a constructor the
compiler has nothing to say. The session adds Pending to a status type and compiles twice:
from session import run, write
# A catch-all case keeps a match exhaustive when the type gains a constructor, so the compiler stays silent. Warning 4
# (fragile-match), off by default, reports such matches.
write("status.ml", """type status = Active | Suspended | Closed | Pending
let label = function
| Active -> "active"
| _ -> "inactive"
let can_log_in = function
| Active -> true
| Suspended | Closed -> false
""")
run("""
ocamlopt -c status.ml
ocamlopt -w +4 -c status.ml
""") Output
$ ocamlopt -c status.ml File "status.ml", lines 7-9, characters 17-31: 7 | .................function 8 | | Active -> true 9 | | Suspended | Closed -> false Warning 8 [partial-match]: this pattern-matching is not exhaustive. Here is an example of a case that is not matched: Pending $ ocamlopt -w +4 -c status.ml File "status.ml", lines 3-5, characters 12-19: 3 | ............function 4 | | Active -> "active" 5 | | _ -> "inactive" Warning 4 [fragile-match]: this pattern-matching is fragile. It will remain exhaustive when constructors are added to type status. File "status.ml", lines 7-9, characters 17-31: 7 | .................function 8 | | Active -> true 9 | | Suspended | Closed -> false Warning 8 [partial-match]: this pattern-matching is not exhaustive. Here is an example of a case that is not matched: Pending
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fragile_session.py
can_log_in, which lists Active, Suspended and Closed explicitly, drew warning 8 naming the new Pending: the
compiler found the place that needs a decision. label, with _ -> "inactive", compiled silently in the first run,
so a pending account would be labelled inactive without anyone choosing that. Only with -w +4 did OCaml report it:
“Warning 4 [fragile-match]: this pattern-matching is fragile. It will remain exhaustive when constructors are added
to type status.” Warning 4 is off by default; the manual’s list of warnings describes it as a fragile match.
| Warning | What it reports | Usual fix |
|---|---|---|
8 partial-match |
a value that reaches no case | add the case, or an unguarded last case |
11 redundant-case |
a case that can never be reached | remove it, or move it above the broader case |
4 fragile-match |
a catch-all on a type that may grow | list the constructors explicitly |
List constructors for types that grow
For a type of states, roles or errors that will gain cases, write every constructor in each match instead of _.
The next new constructor then makes warning 8 list every match that must decide what to do with it.
match with exception handles errors from the value being matched
List.assoc raises Not_found when a key is missing. A case of the form exception Not_found -> … in a match
catches that exception, but only when it is raised while computing the value being matched, not inside a branch. The
session compares it with try … with, which catches the exception wherever it is raised inside the try:
from session import toplevel
# match e with … | exception E -> … handles an exception raised while computing e, the value being matched, and no
# other; try … with also catches exceptions raised inside the branches.
toplevel("""
let ages = [("asha", 31); ("ravi", 27)];;
let age_or_zero name = match List.assoc name ages with a -> a | exception Not_found -> 0;;
(age_or_zero "ravi", age_or_zero "meera");;
let sum_ages a b = match List.assoc a ages with x -> x + List.assoc b ages | exception Not_found -> -1;;
sum_ages "meera" "ravi";;
sum_ages "asha" "meera";;
let sum_ages_try a b = try List.assoc a ages + List.assoc b ages with Not_found -> -1;;
sum_ages_try "asha" "meera";;
""") Output
$ ocaml
OCaml version 5.5.1
Enter #help;; for help.
# let ages = [("asha", 31); ("ravi", 27)];;
val ages : (string * int) list = [("asha", 31); ("ravi", 27)]
# let age_or_zero name = match List.assoc name ages with a -> a | exception Not_found -> 0;;
val age_or_zero : string -> int = <fun>
# (age_or_zero "ravi", age_or_zero "meera");;
- : int * int = (27, 0)
# let sum_ages a b = match List.assoc a ages with x -> x + List.assoc b ages | exception Not_found -> -1;;
val sum_ages : string -> string -> int = <fun>
# sum_ages "meera" "ravi";;
- : int = -1
# sum_ages "asha" "meera";;
Exception: Not_found.
# let sum_ages_try a b = try List.assoc a ages + List.assoc b ages with Not_found -> -1;;
val sum_ages_try : string -> string -> int = <fun>
# sum_ages_try "asha" "meera";;
- : int = -1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lookup_session.py
age_or_zero gave (27, 0): Ravi’s age, then 0 for a missing name. sum_ages "meera" "ravi" gave -1, because the
first lookup, the value being matched, raised Not_found. But sum_ages "asha" "meera" stopped with
Exception: Not_found: the second lookup ran inside the branch, where the exception case does not apply. The try
version caught both and returned -1.
That narrowness is the point. A match … with exception handles the failure of one computation and lets errors in
the code that uses its result surface, while a broad try around a whole expression can catch an exception the
programmer never meant to handle and turn a bug into a silent default.
Key idea
An exception case in a match covers the value being matched and nothing else; reach for try with only when every part of the expression may raise the same exception for the same reason.
Checkpoint
Check: Warnings 8, 11 and 4 · match with exception
Practise: patterns, guards and warnings
The questions use the session’s classify, sign, status and ages with new values and new cases. Decide which
case matches, which warning appears, or whether an exception escapes, before you check:
Practice that adapts to you
Practise this lesson
11 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (2–5 years): why does a match whose guards cover every integer still draw warning 8?
The exhaustiveness check works from the patterns alone. A guard such as when n > 0 is an ordinary expression that
could call any function, so the compiler cannot know when it is true, and it counts every guarded case as one that
may fail. In match n with 0 -> … | n when n > 0 -> … | n when n < 0 -> … only the pattern 0 is unguarded, so OCaml
reports 1 as a possibly unmatched value and adds that some guarded clause may match it. The fix is to make the last
case unguarded, such as | _ -> "negative", which the checker can see covers everything left.
Two harder questions follow, about a status match that silently mislabels a new state and about a lookup that hides real errors. Decide what you would check first, then open each first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A Pending state was added to an account status type. The build was clean, yet support found pending accounts shown as "inactive" on the dashboard. The dashboard's label function ends with | _ -> "inactive". Why did no warning appear, and how would you make the next new state impossible to miss?
-
Mid to senior
A function wraps a whole computation in try … with Not_found -> 0. A bug elsewhere in it, a lookup in the wrong table, now also returns 0 instead of failing, and totals are quietly wrong. How would you restructure the error handling, and why is it safer?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What do or-patterns, as-patterns and guards each add to a match?
An or-pattern matches either side, an as-pattern binds the whole matched value, and a guard adds a when test after the pattern. Cases are tried from the top until one matches.
-
Why is | (0, b) | (a, 0) -> … refused?
Each side of an or-pattern has to bind the same variables. Write | (0, _) | (_, 0) -> …, or two separate cases.
-
Why do the cases 0, n when n > 0 and n when n < 0 still draw warning 8?
The exhaustiveness check reads patterns, not guards, so only 0 counts as covered. End the match with an unguarded case such as | _ -> "negative".
-
What do warnings 8, 11 and 4 each point at?
8: a value no case matches; 11: a case that can never run; 4 (off by default): a catch-all that will swallow constructors added later.
-
Why write every constructor instead of _ for a type that will grow?
Then the next constructor you add makes warning 8 point at each match that has to handle it.
-
How does a match … with exception case differ from try … with?
It catches an exception raised while computing the matched value only; try … with catches one from anywhere inside, the branches included.
-
What does the function keyword do?
It defines a function that matches its single argument against cases directly, without naming the argument first.
Key takeaways
- Or-patterns match either side, as-patterns bind the whole matched value, guards add a test after the pattern, and
cases are tried from the top until one matches;
functionmatches a function’s single argument. - The exhaustiveness check ignores guards, so a guarded match ends with an unguarded case to be provably complete.
- Warning 8 reports a value no case matches, warning 11 a case that can never run, and warning 4, off by default, a catch-all that will swallow constructors added later.
- A
match … with exceptioncase catches an exception from computing the matched value only, whiletry … withcatches it from anywhere inside, including the branches.
Terms in this lesson
- guard
- A condition after a pattern, written when expr, that is tested only once the pattern has matched; if it is false, matching goes on to the next case.
- or-pattern
- A pattern p1 | p2 that matches when either side matches; both sides must bind the same variables.
References
- The OCaml Manual: Patterns (Inria and the OCaml developers)
- The OCaml Manual: Expressions (matching and exceptions) (Inria and the OCaml developers)
- The OCaml Manual: Batch compilation (warning numbers) (Inria and the OCaml developers)
- The OCaml Manual: Generalized algebraic datatypes (refutation cases) (Inria and the OCaml developers)
- Basic Data Types and Pattern Matching (OCaml.org)
Report a problem with this lesson
Kept only in this browser. Your Learn progress