blog :: Post "intro-to-lean"
Introduction to Lean
2026-09-06 · 9 min
Lean is a programming language and a proof assistant in one, and the same syntax does both jobs. This writes a function, states a theorem about it, and proves it, then covers the tactics you will actually use and the ones that find proofs for you.
A little history
Leonardo de Moura started Lean at Microsoft Research in 2013. Lean 3 was where mathlib, the community mathematics library, grew large enough to become the main reason people showed up. Lean 4, released in 2021, was a full rewrite with a different goal: it is self-hosting, most of Lean is written in Lean, and it is meant to be a language you would write ordinary programs in rather than only a proof checker. Its macro system is exposed to users, so tactics and notation are libraries rather than compiler features. Development moved to the Lean Focused Research Organization in 2023.
Install and a project
elan manages Lean toolchains, the way rustup or ghcup do. Install it rather than a Lean release directly, because every project pins its own version.
$ curl https://elan.lean-lang.org/elan-init.sh -sSf | sh
lake is the build tool. This scaffolds a package.
$ lake new playground$ cd playground
What you get. lean-toolchain pins the compiler version, and elan reads it automatically when you enter the directory.
playground/├── Playground/│ └── Basic.lean├── Playground.lean├── lakefile.toml└── lean-toolchain
Build it.
$ lake build
Add mathlib, which you want for anything past arithmetic. In lakefile.toml.
[[require]]name = "mathlib"scope = "leanprover-community"
Fetch prebuilt mathlib artifacts. Skip this and lake compiles mathlib from source, which takes hours.
$ lake update$ lake exe cache get$ lake build
Install the VS Code extension before going further. Lean is written against an interactive goal display, and reading proofs without one is like reading a debugger session as a transcript. Everything below assumes you can see the goal.
The goal state
A theorem is a type, a proof is a term, and := by drops into tactic mode to build that term.
theorem two_plus_two : 2 + 2 = 4 := byrfl
Put the cursor inside a proof and Lean shows the goal. Hypotheses above the turnstile, what you owe below it.
α : Type u_1x : αxs ys : List αih : sum (xs ++ ys) = sum xs + sum ys⊢ sum (x :: xs ++ ys) = sum (x :: xs) + sum ys
sorry admits any goal. It is how you leave a hole and keep working; Lean marks the theorem with a warning so it cannot slip past you.
theorem hard : P = Q := bysorry
The three commands for asking questions, which work anywhere in a file.
#check List.append_assoc -- what is its statement#print List.length -- how is it defined#eval sum [1, 2, 3] -- run it
A proof, end to end
A function, defined by pattern matching. This is ordinary Lean code and compiles to a real program.
def sum : List Nat → Nat| [] => 0| x :: xs => x + sum xs
The theorem worth proving about it: summing a concatenation is the same as summing the parts.
theorem sum_append (xs ys : List Nat) :sum (xs ++ ys) = sum xs + sum ys := bysorry
Induct on the first list. The with block names each case and the induction hypothesis.
theorem sum_append (xs ys : List Nat) :sum (xs ++ ys) = sum xs + sum ys := byinduction xs with| nil => sorry| cons x xs ih => sorry
The base case. [] ++ ys reduces to ys and sum [] to 0, so this is 0 + ys = ys after unfolding.
| nil => simp [sum]
The step case. Unfold sum, rewrite with the induction hypothesis, and reassociate.
| cons x xs ih => simp [sum, ih, Nat.add_assoc]
The whole thing.
theorem sum_append (xs ys : List Nat) :sum (xs ++ ys) = sum xs + sum ys := byinduction xs with| nil => simp [sum]| cons x xs ih => simp [sum, ih, Nat.add_assoc]
The tactics worth knowing
Closing a goal directly.
rfl both sides reduce to the same thingexact h h is precisely the goalassumption some hypothesis is, find ittrivial the goal is True, rfl, or similardecide the proposition is decidable, so compute the answer
Working backwards and forwards.
intro x move a binder from the goal into contextapply f reduce the goal to f's argumentsrefine e ?_ apply, with holes you name and fill laterconstructor split a conjunction or pick a structure's constructorexfalso replace the goal with False
Rewriting. rw rewrites left to right; the arrow reverses it; at h targets a hypothesis instead of the goal.
rw [Nat.add_comm]rw [← Nat.add_assoc]rw [h1, h2] at hypsubst h -- h : a = b, replace b everywhere
Case analysis.
cases xs with| nil => ...| cons x xs => ...rcases h with ⟨a, b⟩ -- destructure a conjunction or existentialobtain ⟨x, hx⟩ := h -- the same thing, reads better forwards
The automation. These are the ones that close most goals in practice.
simp rewrite with the simp set until stucksimp [f, h] ... plus these definitions and hypothesessimp at h ... in a hypothesisomega linear arithmetic over Nat and Intlinarith linear arithmetic over ordered fields (mathlib)positivity this expression is positive (mathlib)norm_num evaluate numeric expressions (mathlib)aesop general-purpose search (mathlib)
Letting Lean find the proof
exact? searches the library for something that closes the goal, and prints what it found.
example (xs ys : List α) : (xs ++ ys).length = xs.length + ys.length := byexact?Try this: exact List.length_append xs ys
simp? runs simp and reports the exact lemmas it used. Replace the call with that output, so a future mathlib change cannot silently alter your proof.
simp?Try this: simp only [List.length_append, Nat.add_comm]
The rest of the search family.
apply? library results whose conclusion matchesexact? ... that close the goal outrightrw? rewrites applicable herehint run several tactics and report which worked
says pins an automation tactic to the output it produced. The proof still runs simp, but the build fails if simp ever starts producing something else, which turns a silent change into a caught one.
example (xs ys : List α) :(xs ++ ys).length = xs.length + ys.length := bysimp says simp only [List.length_append]
Proofs people can read
A proof that is a flat list of tactics works but does not survive contact with a reader, or with a refactor. These are the constructs that give it structure.
have introduces an intermediate result, proved inline, and adds it to the context.
theorem example1 (a b : Nat) (h : a ≤ b) : a ≤ b + 1 := byhave h2 : b ≤ b + 1 := Nat.le_succ bexact Nat.le_trans h h2
calc chains steps into one relation, with each link justified separately. This is the closest thing to how the proof would be written on paper.
theorem example2 (a b c : Nat) (h1 : a = b) (h2 : b = c) : a + 0 = c := bycalc a + 0 = a := Nat.add_zero a_ = b := h1_ = c := h2
Focus dots keep multi-goal tactics honest about which goal each block is closing.
theorem example3 (p q : Prop) (hp : p) (hq : q) : p ∧ q := byconstructor· exact hp· exact hq
show restates the goal in a definitionally equal form, which is often what makes the next tactic apply.
show 0 + n = nsimp
Programs, not just proofs
Lean compiles. A main function makes the package an executable.
def main : IO Unit := dolet xs := [1, 2, 3]IO.println s!"sum = {sum xs}"
Build and run it.
$ lake build$ lake exe playground
The payoff of one language for both: a precondition proved at compile time, so the function cannot be called wrongly and needs no runtime check.
def head! (xs : List α) (h : xs ≠ []) : α :=match xs, h with| x :: _, _ => x#eval head! [1, 2, 3] (by simp)
Proofs are erased at runtime, so h costs nothing in the compiled program.
$ lake build$ ls .lake/build/bin/
That is the loop: state the theorem, drop into tactic mode, induct or case-split on whatever the function recurses on, and let simp and omega finish the arithmetic. When you are stuck, ask exact? before searching mathlib by hand, and replace simp with the simp only that simp? prints once the proof works.