Recursion
Recursion is the primary looping mechanism in Lean. Unlike imperative loops, recursive functions must be provably terminating.
Structural Recursion
The simplest form of recursion follows the structure of the data:
1-- Recursion on Nat2def factorial : Nat → Nat3 | 0 => 14 | n + 1 => (n + 1) * factorial n56-- Recursion on List7def length : List α → Nat8 | [] => 09 | _ :: xs => 1 + length xs1011-- Recursion on custom type12inductive Tree where13 | leaf14 | node (left : Tree) (value : Nat) (right : Tree)1516def Tree.size : Tree → Nat17 | leaf => 018 | node left _ right => 1 + left.size + right.size1920#eval factorial 5 -- 12021#eval length [1, 2, 3, 4] -- 4In each case, the recursive call is on a "smaller" value—a predecessor of a Nat, the tail of a List, or a subtree. This guarantees termination.
Termination Checking
Lean requires proof that all functions terminate. For structural recursion, this is automatic. But some patterns need help:
1-- This works: clearly structural2def sum : List Nat → Nat3 | [] => 04 | x :: xs => x + sum xs56-- This needs a hint: the argument shrinks, but not structurally7def gcd (m n : Nat) : Nat :=8 if h : n = 0 then m9 else gcd n (m % n)10termination_by n11decreasing_by exact Nat.mod_lt _ (Nat.pos_of_ne_zero h)1213#eval gcd 12 18 -- 6The two clauses do different jobs, and you usually need both:
termination_by n— what gets smaller. Here, the second argument.decreasing_by …— why it gets smaller. Lean hands you a goal (m % n < n) and you prove it.
if h : n = 0 rather than if n = 0. The dependent if is what puts h : ¬n = 0 into scope in the else branch — and without it there is no way to prove m % n < n, which is false when n = 0. Forgetting the h : is the single most common reason a termination proof looks impossible.Sometimes termination is genuinely unknown to mathematics. The Collatz function is the classic example — no one has proved it terminates for every input:
1-- Honest answer: mark it partial2partial def collatz (n : Nat) : Nat :=3 if n ≤ 1 then n4 else if n % 2 == 0 then collatz (n / 2)5 else collatz (3 * n + 1)67#eval collatz 27 -- 189-- If you insist on a real def, you must admit the obligation.10-- Note "all_goals": decreasing_by gets one goal PER recursive call,11-- and a bare "sorry" would only close the first.12def collatz' (n : Nat) : Nat :=13 if n ≤ 1 then n14 else if n % 2 == 0 then collatz' (n / 2)15 else collatz' (3 * n + 1)16termination_by n17decreasing_by all_goals sorry -- warning: declaration uses 'sorry'Partial Functions
When you can't prove termination (or don't want to), use partial:
1-- Reading user input until it is valid: may never terminate,2-- and that is the intended behaviour3partial def getValidInput : IO Nat := do4 IO.println "Enter a positive number:"5 let input ← (← IO.getStdin).getLine6 match input.trimAscii.toString.toNat? with7 | some n => return n8 | none =>9 IO.println "Invalid input, try again."10 getValidInput1112-- A loop that runs until told to stop13partial def countdown (n : Nat) : IO Unit := do14 if n == 0 then15 IO.println "liftoff"16 else17 IO.println s!"{n}..."18 countdown (n - 1)1920#eval countdown 3partial is not free. Lean marks the definition opaque: it compiles and runs, but you cannot unfold it in a proof, and rfl, decide, and simp [f] will not see through it. It also requires the return type to be Inhabited, since Lean needs a value to pretend the function returns.1partial def loop (n : Nat) : Nat := if n = 0 then 0 else loop (n - 1)23#eval loop 5 -- 0, runs fine4-- example : loop 5 = 0 := rfl -- fails: loop is opaque to the kernel56-- The fuel pattern: a total function that is "partial enough".7-- Structural on the fuel argument, so Lean accepts it with no proof obligation.8def collatzFuel : Nat → Nat → Option Nat9 | 0, _ => none -- ran out of fuel10 | _, 1 => some 111 | fuel + 1, n =>12 if n % 2 == 0 then collatzFuel fuel (n / 2)13 else collatzFuel fuel (3 * n + 1)1415#eval collatzFuel 200 27 -- some 116#eval collatzFuel 5 27 -- nonesimp and rfl.partialfor functions that intentionally don't terminate (servers, REPLs) or when proving termination is too complex. Regular defrequires termination proof.Tail Recursion
Tail-recursive functions are optimized to use constant stack space:
1-- NOT tail recursive: has pending work after recursive call2def factorialBad : Nat → Nat3 | 0 => 14 | n + 1 => (n + 1) * factorialBad n -- Must multiply AFTER the call56-- Tail recursive: uses an accumulator7def factorialGood (n : Nat) : Nat :=8 go n 19where10 go : Nat → Nat → Nat11 | 0, acc => acc12 | n + 1, acc => go n ((n + 1) * acc) -- Recursive call is last1314-- Both compute the same result15#eval factorialBad 10 -- 362880016#eval factorialGood 10 -- 3628800The tail-recursive version won't overflow the stack even for large inputs.
Tracing Recursive Calls
When a recursive function misbehaves, add a small trace to see the call pattern. This is a practical debugging trick.
1def traceSum : List Nat → Nat2 | [] => dbg_trace "base case"; 03 | x :: xs =>4 dbg_trace s!"visiting {x}, remaining {xs.length}"5 x + traceSum xs67#eval traceSum [1, 2, 3]8-- visiting 1, remaining 29-- visiting 2, remaining 110-- visiting 3, remaining 011-- base case12-- 6 visiting lines print before base case, because each call must recurse fully before it can do its addition. That is exactly what "not tail recursive" looks like — three pending additions stacked up. Compare this with the accumulator version in the next lesson, where the work happens on the way down.Common Recursive Patterns
Map: Transform Each Element
The map pattern applies a function to each element, building a new list with the transformed values. The recursive call processes the tail, and we prepend the transformed head.
1def map (f : α → β) : List α → List β2 | [] => []3 | x :: xs => f x :: map f xs45#eval map (· * 2) [1, 2, 3] -- [2, 4, 6]Filter: Keep Matching Elements
The filter pattern tests each element against a predicate. If the element passes, we include it in the result; otherwise, we skip it. The recursion handles the remaining elements.
1def filter (p : α → Bool) : List α → List α2 | [] => []3 | x :: xs => if p x then x :: filter p xs else filter p xs45#eval filter (· > 2) [1, 2, 3, 4, 5] -- [3, 4, 5]Fold: Accumulate a Result
The fold pattern combines all elements into a single value using an accumulator. Each step updates the accumulator with the next element until the list is exhausted.
1def foldl (f : β → α → β) (init : β) : List α → β2 | [] => init3 | x :: xs => foldl f (f init x) xs45#eval foldl (· + ·) 0 [1, 2, 3, 4, 5] -- 15Deep Dive: Why Structural vs Well-Founded Matters
The distinction is not just about which incantation you write. Lean compiles the two kinds of recursion differently, and that difference shows up in your proofs.
A structurally recursivefunction compiles to the type's recursor. Its defining equations hold by definition, so rfl and decide can unfold it and simp uses its equations freely.
A well-founded function (one with termination_by) compiles through WellFounded.fix. Its equations are theorems, not definitional truths, so rfl often fails where you expect it to work. You unfold with the generated f.eq_def or simp [f] instead.
1-- Structural: this holds by rfl2def len : List α → Nat3 | [] => 04 | _ :: xs => len xs + 156example : len [1, 2] = 2 := rfl -- ✓78-- Well-founded: prefer the equation lemmas over rfl9def gcd' (m n : Nat) : Nat :=10 if h : n = 0 then m else gcd' n (m % n)11termination_by n12decreasing_by exact Nat.mod_lt _ (Nat.pos_of_ne_zero h)1314example : gcd' 12 18 = 6 := by simp [gcd'] -- or: by decide, or by native_decidePractical advice: prefer structural recursion when you can restructure the function to get it, especially for anything you plan to prove things about. Reach for termination_by when the natural definition genuinely is not structural.
Mutual Recursion
Functions that call each other use mutual:
1mutual2 def isEven : Nat → Bool3 | 0 => true4 | n + 1 => isOdd n56 def isOdd : Nat → Bool7 | 0 => false8 | n + 1 => isEven n9end1011#eval isEven 4 -- true12#eval isOdd 7 -- trueRecursion vs Iteration
Lean provides for loops in the donotation, but they're syntactic sugar for recursion:
1-- Imperative-looking but actually recursive2def sumArray (arr : Array Nat) : Nat := Id.run do3 let mut total := 04 for x in arr do5 total := total + x6 return total78-- Equivalent explicit recursion9def sumArray' (arr : Array Nat) : Nat :=10 arr.foldl (· + ·) 01112#eval sumArray #[1, 2, 3, 4, 5] -- 15Implement exponentiation for natural numbers using recursion.
1def pow : Nat → Nat → Nat2 | _, 0 => 13 | x, n + 1 => x * pow x n45#eval pow 2 10 -- 1024