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.

§ 01

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.

§ 02

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.

§ 03

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 := by
rfl

Put the cursor inside a proof and Lean shows the goal. Hypotheses above the turnstile, what you owe below it.

α : Type u_1
x : α
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 := by
sorry

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
§ 04

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 := by
sorry

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 := by
induction 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 := by
induction xs with
| nil => simp [sum]
| cons x xs ih => simp [sum, ih, Nat.add_assoc]
§ 05

The tactics worth knowing

Closing a goal directly.

rfl both sides reduce to the same thing
exact h h is precisely the goal
assumption some hypothesis is, find it
trivial the goal is True, rfl, or similar
decide the proposition is decidable, so compute the answer

Working backwards and forwards.

intro x move a binder from the goal into context
apply f reduce the goal to f's arguments
refine e ?_ apply, with holes you name and fill later
constructor split a conjunction or pick a structure's constructor
exfalso 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 hyp
subst 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 existential
obtain ⟨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 stuck
simp [f, h] ... plus these definitions and hypotheses
simp at h ... in a hypothesis
omega linear arithmetic over Nat and Int
linarith linear arithmetic over ordered fields (mathlib)
positivity this expression is positive (mathlib)
norm_num evaluate numeric expressions (mathlib)
aesop general-purpose search (mathlib)
§ 06

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 := by
exact?
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 matches
exact? ... that close the goal outright
rw? rewrites applicable here
hint 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 := by
simp says simp only [List.length_append]
§ 07

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 := by
have h2 : b ≤ b + 1 := Nat.le_succ b
exact 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 := by
calc 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 := by
constructor
· 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 = n
simp
§ 08

Programs, not just proofs

Lean compiles. A main function makes the package an executable.

def main : IO Unit := do
let 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.