Practise it in the Academy:Do notation, desugared →

Module 5 · Lesson 1

Do Notation

do notation lets you write steps that have effects or may fail one after another, in an imperative style. Almost every Lean program that does input or output uses it.

The Problem: Nested Binds

Chaining operations that return Option or Exceptgets messy fast:

lean
Try it
1def lookup (key : String) (env : List (String × Nat)) : Option Nat :=
2 env.lookup key
3
4-- Without do notation: deeply nested
5def compute (env : List (String × Nat)) : Option Nat :=
6 match lookup "x" env with
7 | none => none
8 | some x => match lookup "y" env with
9 | none => none
10 | some y => match lookup "z" env with
11 | none => none
12 | some z => some (x + y + z)

Do Notation Cleans This Up

lean
1-- With do notation: flat and readable
2def compute' (env : List (String × Nat)) : Option Nat := do
3 let x ← lookup "x" env
4 let y ← lookup "y" env
5 let z ← lookup "z" env
6 return x + y + z
7
8def env := [("x", 1), ("y", 2), ("z", 3)]
9#eval compute' env -- some 6
Key Takeaway
The ← extracts a value from Option. If any extraction fails (none), the whole block returnsnone.

How Do Notation Works

do desugars to bind operations:

lean
Try it
1-- This do block:
2def example1 : Option Nat := do
3 let x ← some 5
4 let y ← some 3
5 return x + y
6
7-- Is equivalent to:
8def example2 : Option Nat :=
9 (some 5).bind fun x =>
10 (some 3).bind fun y =>
11 pure (x + y)
12
13-- Or using >>= operator:
14def example3 : Option Nat :=
15 some 5 >>= fun x =>
16 some 3 >>= fun y =>
17 pure (x + y)

Key Constructs

let ← : Extract Value

The arrow ← unwraps a value from a monadic context. ForOption, it extracts the inner value fromsome; for none, it short-circuits the entire block.

lean
Try it
1def safeDivide (x y : Nat) : Option Nat :=
2 if y == 0 then none else some (x / y)
3
4def calculation : Option Nat := do
5 let a ← safeDivide 10 2 -- a = 5
6 let b ← safeDivide a 0 -- Fails! Returns none
7 let c ← safeDivide b 1 -- Never reached
8 return c

return : Wrap Value

The return keyword wraps a pure value back into the monadic type. For Option it produces some; for IO it produces a completed action.

lean
Try it
1-- return wraps a pure value
2def justFive : Option Nat := do
3 return 5 -- same as: some 5
4
5-- Can be anywhere in the block
6def earlyReturn : Option Nat := do
7 let x ← some 3
8 if x > 10 then
9 return 0 -- Early return
10 return x * 2

let := : Pure Binding

When a computation is pure (no effects), use := instead of ←. This is an ordinary local binding — the same let you use outside a do block.

lean
Try it
1def pureBinding : Option Nat := do
2 let x ← some 5 -- ← extracts from Option
3 let y := x * 2 -- := is pure computation
4 let z := y + 1 -- no Option involved
5 return z -- some 11
⚠
It is :=, not =. Writing let y = x * 2 gives unexpected token, expected ':='. The single = is equality (a proposition), never assignment — that is true everywhere in Lean, inside do blocks included.
ℹ
Use ← when the right-hand side is in the monad(Option Nat, IO String) and you want the value inside. Use := when it is already a plain value. If you get type mismatch on a let, you almost certainly picked the wrong one.

(← e) : Arrows Inside Expressions

You do not need a let for every extraction. An ← can appear anywhere inside an expression; Lean lifts it out into a binding for you, left to right.

lean
Try it
1def verbose : Option Nat := do
2 let x ← some 5
3 let y ← some 3
4 return x + y
5
6-- Same thing, no intermediate names
7def concise : Option Nat := do
8 return (← some 5) + (← some 3)
9
10#eval concise -- some 8
11
12-- Very common in IO
13#eval show IO Unit from do
14 IO.println s!"cwd: {← IO.currentDir}"
⚠
The lifting happens at the nearest enclosing do, and in evaluation order. That matters when the effects are not commutative: f (← a) (← b) runs a then b, always. If the order is important to a reader, write the lets out — clarity beats brevity here.

Do Works with Many Types

Option

lean
Try it
1def optionExample : Option String := do
2 let x ← some "hello"
3 let y ← some "world"
4 return s!"{x} {y}"

Except

lean
Try it
1def divide (x y : Int) : Except String Int :=
2 if y == 0 then .error "division by zero" else .ok (x / y)
3
4def exceptExample : Except String Int := do
5 let a ← divide 10 2
6 let b ← divide a 0 -- Fails with error message
7 return b

List (Non-determinism)

In many languages List is a monad, and do over it means "all combinations". Lean core deliberately does not ship that instance — writing a do block over a list fails with failed to synthesize Pure List. Use flatMap directly, which is what the desugaring would have produced anyway:

lean
Try it
1-- What "do over List" would mean, written out:
2def pairs : List (Nat × Nat) :=
3 [1, 2].flatMap fun x =>
4 [10, 20].map fun y =>
5 (x, y)
6
7#eval pairs -- [(1, 10), (1, 20), (2, 10), (2, 20)]
8
9-- The same thing with the standard combinator
10#eval [1, 2].flatMap (fun x => [10, 20].map (fun y => (x, y)))
ℹ
Mathlib and Batteries do provide Monad List. If you have one of them imported, the do version works. Relying on it is fine if you know the instance is in scope — this is a good example of why "instance not found" is so often really "import not present".

IO (Side Effects)

lean
Try it
1def greet : IO Unit := do
2 IO.println "What's your name?"
3 let stdin ← IO.getStdin
4 let name ← stdin.getLine
5 IO.println s!"Hello, {name.trim}!"

Control Flow in Do Blocks

if-then-else

lean
Try it
1def classify (n : Nat) : IO Unit := do
2 if n == 0 then
3 IO.println "zero"
4 else if n < 10 then
5 IO.println "small"
6 else
7 IO.println "large"

for Loops

lean
Try it
1def printNumbers : IO Unit := do
2 for i in [1, 2, 3, 4, 5] do
3 IO.println s!"Number: {i}"
4
5def sumList (xs : List Nat) : IO Nat := do
6 let mut sum := 0
7 for x in xs do
8 sum := sum + x
9 return sum

while Loops

lean
Try it
1def countdown : IO Unit := do
2 let mut n := 5
3 while n > 0 do
4 IO.println s!"{n}..."
5 n := n - 1
6 IO.println "Liftoff!"

One-Armed if and unless

Inside a do block, an if needs no else, and unless runs its block when the condition is false. Lean 4 has no when keyword; the one-armed if takes its place:

lean
Try it
1-- A one-armed if: run the block only when the condition holds
2def maybeLog (verbose : Bool) (msg : String) : IO Unit := do
3 if verbose then
4 IO.println s!"[LOG] {msg}"
5
6-- unless: run the block only when the condition is false
7def requirePositive (n : Int) : IO Unit := do
8 unless n > 0 do
9 IO.println "Error: expected positive number"
10 return
11 IO.println s!"Processing {n}"
12
13-- Neither needs an else branch that does nothing
14#eval maybeLog true "Hello" -- prints "[LOG] Hello"
15#eval maybeLog false "Hello" -- prints nothing

Match Inside Do

Pattern matching integrates naturally with do notation:

lean
Try it
1def processResult (r : Except String Nat) : IO Unit := do
2 match r with
3 | .error e => IO.println s!"Error: {e}"
4 | .ok n =>
5 IO.println s!"Got value: {n}"
6 let doubled := n * 2
7 IO.println s!"Doubled: {doubled}"
8
9-- Match with ←
10def processFile (path : String) : IO String := do
11 let content ← IO.FS.readFile path
12 match content.splitOn "\n" with
13 | [] => return "empty file"
14 | first :: _ => return first

Early Exit Patterns

Combine if with return to exit a block early. This keeps control flow clear without deeply nested branches.

lean
Try it
1def ensurePositive (n : Int) : Except String Int := do
2 if n <= 0 then
3 throw "expected positive" -- NOT: return .error "..."
4 return n -- NOT: return .ok n
5
6#eval ensurePositive (-1) -- Except.error "expected positive"
7#eval ensurePositive 5 -- Except.ok 5
⚠
Inside a do block, return ealready wraps e in the monad. So return .ok n means .ok (.ok n) and does not typecheck, and return .error e would build a success containing an error value. To fail, use throw.
Deep Dive: What is a Monad?

do notation works with any type that has aMonad instance. A monad provides:

lean
1class Monad (m : Type → Type) where
2 pure : α → m α -- Wrap a value
3 bind : m α → (α → m β) → m β -- Chain operations

Option, Except,List, and IO are all monads.

Combining Different Monads

You can't mix monads directly in one do block:

lean
Try it
1-- This doesn't work:
2-- def bad : IO Nat := do
3-- let x ← some 5 -- Option, not IO!
4-- return x
5
6-- Convert Option to IO by handling the failure:
7def good : IO Nat := do
8 let opt : Option Nat := some 5
9 match opt with
10 | none => return 0
11 | some x => return x
Exercise: Option Pipeline

Use do notation to chain two Option computations.

lean
Try it
1def parseNat (s : String) : Option Nat := s.toNat?
2
3def sumTwo (a b : String) : Option Nat := do
4 let x ← parseNat a
5 let y ← parseNat b
6 return x + y
7
8#eval sumTwo "3" "4" -- some 7
9#eval sumTwo "3" "x" -- none