Recursion
Recursion is Lean's core looping mechanism. If you can define a function by structural recursion, Lean guarantees it terminates — and when you cannot, Lean asks you to prove it, which is a conversation worth learning how to have.
Learning Goals
- Write structurally recursive functions over
NatandList. - Explain why Lean insists every function terminates.
- Convert a naive recursion into an accumulator-passing one, and know when it matters.
- Use
termination_byanddecreasing_bywhen the recursion is not structural. - Know what
partial defbuys you and what it costs.
Recursive Functions on Nat
A structurally recursive function calls itself on a strictly smaller piece of its input. For Nat, that means the n + 1 pattern from Level 6: match on it, and recurse on n.
1def factorial : Nat → Nat2 | 0 => 13 | n + 1 => (n + 1) * factorial n45#eval factorial 5 -- 1206#eval factorial 10 -- 3628800Two base cases are just as easy. fib peels off two at a time, so it needs a rule for 0 and one for 1.
1def fib : Nat → Nat2 | 0 => 03 | 1 => 14 | n + 2 => fib n + fib (n + 1)56#eval fib 10 -- 557#eval (List.range 10).map fib -- [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]Recursive Functions on Lists
Lists are recursive by nature, so the shape of the function mirrors the shape of the data: one branch for [], one for x :: xs that recurses on xs.
1def sumList : List Nat → Nat2 | [] => 03 | x :: xs => x + sumList xs45#eval sumList [1, 2, 3] -- 667def myLength : List α → Nat8 | [] => 09 | _ :: xs => 1 + myLength xs1011def myMap (f : α → β) : List α → List β12 | [] => []13 | x :: xs => f x :: myMap f xs1415#eval myMap (· * 2) [1, 2, 3] -- [2, 4, 6][]case supplies the “starting value” and the ::case says how to combine the head with the answer for the tail. Once you see it, you can write most list functions without thinking hard — and it is exactly the skeleton your induction proofs will have in Level 8.Why Lean Insists on Termination
Lean will reject a recursive definition it cannot see terminating. This is not fussiness. In Lean, definitions and proofs are the same thing, and a non-terminating definition would let you prove anything at all:
1-- If this were allowed, it would "prove" any proposition P2-- def bad (P : Prop) : P := bad PA function that never returns could claim any type, including False. Rejecting non-termination is what keeps the whole logic sound. When Lean complains about termination it is protecting the thing that makes it worth using.
Accumulators and Tail Recursion
The naive definitions above build up work on the way out of the recursion. Passing an accumulator instead does the work on the way in, so the recursive call is the very last thing that happens — a tail call, which Lean compiles into a loop with no stack growth.
1def sumAcc : List Nat → Nat → Nat2 | [], acc => acc3 | x :: xs, acc => sumAcc xs (acc + x)45#eval sumAcc [1, 2, 3] 0 -- 667-- A helper defined with "where" keeps the clean signature on the outside8def fastReverse (xs : List α) : List α :=9 go xs []10where11 go : List α → List α → List α12 | [], acc => acc13 | x :: xs, acc => go xs (x :: acc)1415#eval fastReverse [1, 2, 3, 4] -- [4, 3, 2, 1]Sometimes the rewrite changes the complexity class, not just the constant factor. Naive fib recomputes the same values exponentially many times; fib 90 would not finish in your lifetime. Carrying the last two values along makes it linear.
1def fibFast (n : Nat) : Nat :=2 go n 0 13where4 go : Nat → Nat → Nat → Nat5 | 0, a, _ => a6 | k + 1, a, b => go k b (a + b)78#eval fibFast 10 -- 559#eval fibFast 90 -- 2880067194370816120 (instant)naiveReverse using xs ++ [x] is quadratic, because ++ walks the whole left list every time. The accumulator version is linear. Both are correct; only one is usable on a big list.Mutual Recursion
Two functions can call each other if you group them in a mutual block. Lean checks that the pair together makes progress.
1mutual2 def isEven : Nat → Bool3 | 0 => true4 | n + 1 => isOdd n56 def isOdd : Nat → Bool7 | 0 => false8 | n + 1 => isEven n9end1011#eval isEven 10 -- true12#eval isOdd 10 -- falseWhen Recursion Is Not Structural
Euclid's algorithm recurses on a % b, which is not a “peel one off” sub-term of anything. Lean cannot see the decrease by itself, and says so clearly:
1def gcd' (a b : Nat) : Nat :=2 if _h : b = 0 then a3 else gcd' b (a % b)4termination_by b56-- error: failed to prove termination7-- a b : Nat8-- _h : ¬b = 09-- ⊢ a % b < bRead that carefully, because it is a good error. termination_by b told Lean which quantity to watch, and Lean worked out the exact obligation that remains: show a % b < b. It even put the hypothesis you need in scope. decreasing_by is where you discharge it.
1def gcd' (a b : Nat) : Nat :=2 if _h : b = 0 then a3 else gcd' b (a % b)4termination_by b5decreasing_by exact Nat.mod_lt _ (Nat.pos_of_ne_zero _h)67#eval gcd' 12 18 -- 68#eval gcd' 48 18 -- 69#eval gcd' 7 13 -- 1if _h : b = 0 rather than plain if b = 0. The named form is a dependent if, which brings the hypothesis _h : ¬b = 0 into scope in the else branch. Without it you have no way to prove the modulus is smaller, because for b = 0 it is not.partial def: The Escape Hatch
Some functions really might not terminate, or the proof is more trouble than it is worth. partial def tells Lean to compile the function without a termination proof.
1partial def collatzLen (n : Nat) : Nat :=2 if n <= 1 then 03 else if n % 2 == 0 then 1 + collatzLen (n / 2)4 else 1 + collatzLen (3 * n + 1)56#eval collatzLen 27 -- 111Nobody knows whether the Collatz sequence always reaches 1, so no termination proof is available — that is a genuine open problem in mathematics. partial is the honest answer here.
partial function is opaque to the logic. You can run it, but you cannot unfold it in a proof or prove anything about its results. Use it for I/O, tooling, and genuinely unbounded loops — not to dodge a termination argument you could actually make.Deep Dive: How Lean actually checks termination
Structural recursion is handled by translating your definition into an application of the type's recursor — for Nat, a primitive called Nat.rec that is guaranteed to terminate by construction. Nothing needs to be proved because the translation only succeeds when the recursion really is structural.
When that fails, Lean falls back to well-founded recursion. You supply a measure with termination_by— a quantity that maps each call into a type with no infinite descending chains, usually Nat— and then prove with decreasing_by that every recursive call strictly decreases it. Since you cannot descend forever in Nat, the recursion must stop.
That same recursor, Nat.rec, is what the induction tactic uses in Level 8. Recursion and induction are genuinely the same mechanism: one builds a value, the other builds a proof.
Practice
Write a function that returns the length of a list of strings.
Show solution
1def countStrings : List String → Nat2 | [] => 03 | _ :: xs => 1 + countStrings xs45#eval countStrings ["a", "b", "c"] -- 3Write power base n computing base raised to the n. Recurse on the exponent, not the base — that is the argument that shrinks.
Show solution
1def power (base : Nat) : Nat → Nat2 | 0 => 13 | n + 1 => base * power base n45#eval power 2 10 -- 1024Keeping base to the left of the colon marks it as fixed across the recursion, which makes the shrinking argument obvious to both Lean and the reader.
Write maximum returning the largest element of a List Nat. You will have to decide what an empty list should return — think about which choice makes the recursive case come out right.
Show solution
1def maximum : List Nat → Nat2 | [] => 03 | x :: xs => max x (maximum xs)45#eval maximum [3, 9, 2, 7] -- 9Returning 0 for the empty list works because 0 is the smallest Nat, so it never wins a max. That trick would not survive a move to Int— there you would want to return Option Int instead, as in Level 2.
Write flatten which turns a List (List Nat) into a single list containing every element in order.
Show solution
1def flatten : List (List Nat) → List Nat2 | [] => []3 | xs :: rest => xs ++ flatten rest45#eval flatten [[1, 2], [3], [4, 5]] -- [1, 2, 3, 4, 5]Common Mistakes
- Recursing on the wrong argument. In
power, the exponent shrinks and the base does not. - Forgetting the base case.You will get a “Missing cases” error from Level 6, not an infinite loop — Lean catches it first.
- Using plain
ifwhere you need the hypothesis.decreasing_byoften needsif h : cond. - Reaching for
partialtoo early. It makes the function unprovable forever. Trytermination_byfirst.
termination_by and discharge the obligation with decreasing_by— the same recursor that makes this work is what powers induction in the next level.Check Yourself
- Why would allowing non-terminating definitions make Lean unsound?
- What makes
sumAcctail-recursive butsumListnot? - What does
termination_by btell Lean, and what does it leave you to prove? - Name one thing you can no longer do with a
partial def.