Pattern Matching
Pattern matching is the primary control flow mechanism in Lean. It's how you deconstruct data and handle different cases elegantly.
The Match Expression
Use match to inspect values and branch based on their structure:
1def describe (n : Nat) : String :=2 match n with3 | 0 => "zero"4 | 1 => "one"5 | 2 => "two"6 | _ => "many" -- underscore matches anything78#eval describe 0 -- "zero"9#eval describe 5 -- "many"Each branch has a pattern (left of =>) and a result (right of =>). Lean checks patterns from top to bottom.
Function Definition Syntax
For simple functions, you can pattern match directly in the definition:
1-- Using match2def factorial1 (n : Nat) : Nat :=3 match n with4 | 0 => 15 | n + 1 => (n + 1) * factorial1 n67-- Direct pattern matching (cleaner)8def factorial2 : Nat → Nat9 | 0 => 110 | n + 1 => (n + 1) * factorial2 n1112#eval factorial2 5 -- 120match) is often cleaner for simple functions. Use match when you need to match in the middle of a larger expression.Matching on Inductive Types
Pattern matching shines when working with inductive types:
1inductive Shape where2 | circle (radius : Float)3 | rectangle (width : Float) (height : Float)4 | triangle (base : Float) (height : Float)56def area : Shape → Float7 | Shape.circle r => 3.14159 * r * r8 | Shape.rectangle w h => w * h9 | Shape.triangle b h => 0.5 * b * h1011def perimeter : Shape → Float12 | Shape.circle r => 2.0 * 3.14159 * r13 | Shape.rectangle w h => 2.0 * (w + h)14 | Shape.triangle _ _ => 0.0 -- would need side lengths1516#eval area (Shape.rectangle 3.0 4.0) -- 12.000000Exhaustiveness Checking
Lean refuses to compile if you miss a case:
1inductive Color where2 | red | green | blue34-- This won't compile!5-- def toHex : Color → String6-- | Color.red => "#FF0000"7-- | Color.green => "#00FF00"8-- -- Missing blue case!910-- Complete version:11def toHex : Color → String12 | Color.red => "#FF0000"13 | Color.green => "#00FF00"14 | Color.blue => "#0000FF"Order Matters: First Match Wins
Branches are tried top to bottom, and the first one that matches wins. Lean also tells you when a branch can never be reached:
1-- Overlapping patterns: (0, 0) matches the first branch, not the second2def sumPair : Nat × Nat → Nat3 | (0, b) => b4 | (a, 0) => a5 | (a, b) => a + b67#eval sumPair (0, 0) -- 0, via the FIRST branch89-- Putting the catch-all first makes later branches dead code,10-- and Lean says so rather than silently ignoring them:11-- def f : Nat → String12-- | _ => "catch-all"13-- | 0 => "zero"14-- error: Redundant alternative: Any expression matching 015-- will match one of the preceding alternativesMatching Nat: the n + 1 Pattern
Nat looks like a primitive but is an inductive type with constructors zero and succ. The pattern n + 1 is sugar for Nat.succ n:
1def pred : Nat → Nat2 | 0 => 03 | n + 1 => n45#print pred6-- fun x => match x with7-- | 0 => 08-- | n.succ => n ← n + 1 was compiled to Nat.succ n910-- Bigger literal patterns work too, and peel off that many succs11def describeSmall : Nat → String12 | 0 => "zero"13 | 1 => "one"14 | n + 2 => s!"{n} plus two"1516#eval describeSmall 5 -- "3 plus two"n + 1 spelling is not cosmetic. It is what makes induction and simp line up with your definition later, and it is why the standard library writes xs.length + 1 rather than 1 + xs.length everywhere.Matching Nothing: nomatch
Some types have no values at all. If you have one in your hands, you are in an impossible situation and can produce anything — nomatchsays exactly that.
1-- Empty has no constructors, so there is no case to write2def fromEmpty (x : Empty) : Nat := nomatch x34-- The same trick appears in proofs: from a contradiction, anything follows5example (h : False) : 2 + 2 = 5 := absurd h (by simp)Destructuring Structures
Pattern matching works on structures too:
1structure Point where2 x : Float3 y : Float45-- Destructure in the pattern6def distanceFromOrigin : Point → Float7 | ⟨x, y⟩ => Float.sqrt (x * x + y * y)89-- Equivalent using field access10def distanceFromOrigin' (p : Point) : Float :=11 Float.sqrt (p.x * p.x + p.y * p.y)1213#eval distanceFromOrigin ⟨3.0, 4.0⟩ -- 5.000000Nested Patterns
Patterns can be nested to match complex structures. This is powerful when you have data types inside other data types, such as a list containing optional values.
1-- Match on nested structures2def firstElement : List (Option Nat) → Option Nat3 | [] => none4 | none :: _ => none5 | some n :: _ => some n67#eval firstElement [some 1, none] -- some 18#eval firstElement [none, some 2] -- none910-- Match on pairs11def addPair : Nat × Nat → Nat12 | (a, b) => a + b1314#eval addPair (3, 4) -- 7Matching on Multiple Inputs
You can pattern match on multiple arguments at once, which is perfect for combining two values or two lists.
1def addOptions : Option Nat → Option Nat → Option Nat2 | some a, some b => some (a + b)3 | _, _ => none45#eval addOptions (some 2) (some 3) -- some 56#eval addOptions none (some 3) -- noneConditions in Branches
Lean does not have Haskell-style pattern guards — there is no | n if n > 0 => …. Instead, match on structure and use if inside the branch:
1def classifyNumber (n : Int) : String :=2 match n with3 | 0 => "zero"4 | n => if n > 0 then "positive" else "negative"56#eval classifyNumber (-3) -- "negative"78-- When every case is a condition rather than a shape, skip match entirely9def grade (score : Nat) : String :=10 if score >= 90 then "A"11 else if score >= 80 then "B"12 else if score >= 70 then "C"13 else if score >= 60 then "D"14 else "F"1516#eval grade 85 -- "B"matchwhen you are asking "what shape is this value?" and for ifwhen you are asking "is this condition true?". A match with a single catch-all pattern and a chain of ifs inside it is always the ifchain written the long way.Remembering What You Matched: match h : e with
A plain match forgets the connection between the scrutinee and the branch you took. Naming the equation with match h : e with keeps it, which is essential once you start proving things about the function.
1def lookupAt (xs : List Nat) (i : Nat) : String :=2 match h : xs[i]? with3 | none => "out of range"4 -- inside this branch, h : xs[i]? = some v5 | some v => s!"found {v}"67#eval lookupAt [1, 2, 3] 1 -- "found 2"h :, once you are inside the some v branch Lean knows v exists but has thrown away the fact that it came from xs[i]?. That fact is exactly what a later proof needs. The cost of writing h : is nothing; the cost of omitting it is sometimes restructuring the whole function.Or-Patterns
Match multiple patterns with the same result:
1inductive Weekday where2 | monday | tuesday | wednesday | thursday | friday | saturday | sunday34def isWeekend : Weekday → Bool5 | .saturday | .sunday => true6 | _ => false78-- Without or-patterns (more verbose)9def isWeekend' : Weekday → Bool10 | .saturday => true11 | .sunday => true12 | _ => false1314#eval isWeekend .saturday -- true| .circle r | .rectangle w h => … is rejected, because the two sides disagree about what is in scope. Or-patterns are for cases you treat identically and whose contents you do not need.As-Patterns
Use @ to bind a name while also destructuring:
1-- Bind the whole list while also matching head and tail2def processFirst : List Nat → (Nat × List Nat)3 | xs@(x :: _) => (x, xs) -- xs is the whole list, x is the first element4 | [] => (0, [])56#eval processFirst [1, 2, 3] -- (1, [1, 2, 3])7#eval processFirst [] -- (0, [])89-- Useful when you need both the structure and its parts10def describeOpt : Option Nat → String11 | opt@(some n) => s!"Value {n}, original: {repr opt}"12 | none => "None"1314#eval describeOpt (some 1) -- "Value 1, original: some 1"@ whenever it starts with a bracket. xs@[] does not parse: Lean reads @[as the start of an attribute and reports a confusing "unexpected token". Write xs@([]), or just match [] directly — there is nothing to name in an empty list.If-Let and Let-Else
For simple pattern matching with an alternative, use if let:
1-- if let: match a pattern or take else branch2def getFirst (xs : List Nat) : Nat :=3 if let x :: _ := xs then x else 045#eval getFirst [1, 2, 3] -- 16#eval getFirst [] -- 078-- Works with any pattern9def extractValue (opt : Option String) : String :=10 if let some s := opt then s else "default"1112-- let-else: match or diverge early13def processOption (opt : Option Nat) : IO Nat := do14 let some n := opt | return 0 -- Early return if none15 IO.println s!"Processing {n}"16 return n * 2if let when you have one pattern and a simple fallback. Use match when you have multiple cases or complex logic.Deep Dive: Pattern Matching Compiles to Efficient Code
Lean's pattern matching compiles to efficient decision trees. The compiler analyzes patterns and generates optimal branching code—often better than hand-written if-else chains.
This means you can write clear, declarative patterns without worrying about performance.
Matching on Lists
Lists are commonly pattern-matched to process elements:
1def sum : List Nat → Nat2 | [] => 03 | x :: xs => x + sum xs45def head? : List α → Option α6 | [] => none7 | x :: _ => some x89def tail? : List α → Option (List α)10 | [] => none11 | _ :: xs => some xs1213def take : Nat → List α → List α14 | 0, _ => []15 | _, [] => []16 | n + 1, x :: xs => x :: take n xs1718#eval sum [1, 2, 3, 4, 5] -- 1519#eval take 2 [1, 2, 3, 4, 5] -- [1, 2]Write a function that returns both the head and tail of a list if it exists.
1def headTail? : List α → Option (α × List α)2 | [] => none3 | x :: xs => some (x, xs)45#eval headTail? [1, 2, 3] -- some (1, [2, 3])6#eval headTail? ([] : List Nat) -- none