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:
1def lookup (key : String) (env : List (String × Nat)) : Option Nat :=2 env.lookup key34-- Without do notation: deeply nested5def compute (env : List (String × Nat)) : Option Nat :=6 match lookup "x" env with7 | none => none8 | some x => match lookup "y" env with9 | none => none10 | some y => match lookup "z" env with11 | none => none12 | some z => some (x + y + z)Do Notation Cleans This Up
1-- With do notation: flat and readable2def compute' (env : List (String × Nat)) : Option Nat := do3 let x ← lookup "x" env4 let y ← lookup "y" env5 let z ← lookup "z" env6 return x + y + z78def env := [("x", 1), ("y", 2), ("z", 3)]9#eval compute' env -- some 6← extracts a value from Option. If any extraction fails (none), the whole block returnsnone.How Do Notation Works
do desugars to bind operations:
1-- This do block:2def example1 : Option Nat := do3 let x ← some 54 let y ← some 35 return x + y67-- Is equivalent to:8def example2 : Option Nat :=9 (some 5).bind fun x =>10 (some 3).bind fun y =>11 pure (x + y)1213-- 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.
1def safeDivide (x y : Nat) : Option Nat :=2 if y == 0 then none else some (x / y)34def calculation : Option Nat := do5 let a ← safeDivide 10 2 -- a = 56 let b ← safeDivide a 0 -- Fails! Returns none7 let c ← safeDivide b 1 -- Never reached8 return creturn : 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.
1-- return wraps a pure value2def justFive : Option Nat := do3 return 5 -- same as: some 545-- Can be anywhere in the block6def earlyReturn : Option Nat := do7 let x ← some 38 if x > 10 then9 return 0 -- Early return10 return x * 2let := : 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.
1def pureBinding : Option Nat := do2 let x ← some 5 -- ← extracts from Option3 let y := x * 2 -- := is pure computation4 let z := y + 1 -- no Option involved5 return z -- some 11:=, 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.← 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.
1def verbose : Option Nat := do2 let x ← some 53 let y ← some 34 return x + y56-- Same thing, no intermediate names7def concise : Option Nat := do8 return (← some 5) + (← some 3)910#eval concise -- some 81112-- Very common in IO13#eval show IO Unit from do14 IO.println s!"cwd: {← IO.currentDir}"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
1def optionExample : Option String := do2 let x ← some "hello"3 let y ← some "world"4 return s!"{x} {y}"Except
1def divide (x y : Int) : Except String Int :=2 if y == 0 then .error "division by zero" else .ok (x / y)34def exceptExample : Except String Int := do5 let a ← divide 10 26 let b ← divide a 0 -- Fails with error message7 return bList (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:
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)67#eval pairs -- [(1, 10), (1, 20), (2, 10), (2, 20)]89-- The same thing with the standard combinator10#eval [1, 2].flatMap (fun x => [10, 20].map (fun y => (x, y)))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)
1def greet : IO Unit := do2 IO.println "What's your name?"3 let stdin ← IO.getStdin4 let name ← stdin.getLine5 IO.println s!"Hello, {name.trim}!"Control Flow in Do Blocks
if-then-else
1def classify (n : Nat) : IO Unit := do2 if n == 0 then3 IO.println "zero"4 else if n < 10 then5 IO.println "small"6 else7 IO.println "large"for Loops
1def printNumbers : IO Unit := do2 for i in [1, 2, 3, 4, 5] do3 IO.println s!"Number: {i}"45def sumList (xs : List Nat) : IO Nat := do6 let mut sum := 07 for x in xs do8 sum := sum + x9 return sumwhile Loops
1def countdown : IO Unit := do2 let mut n := 53 while n > 0 do4 IO.println s!"{n}..."5 n := n - 16 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:
1-- A one-armed if: run the block only when the condition holds2def maybeLog (verbose : Bool) (msg : String) : IO Unit := do3 if verbose then4 IO.println s!"[LOG] {msg}"56-- unless: run the block only when the condition is false7def requirePositive (n : Int) : IO Unit := do8 unless n > 0 do9 IO.println "Error: expected positive number"10 return11 IO.println s!"Processing {n}"1213-- Neither needs an else branch that does nothing14#eval maybeLog true "Hello" -- prints "[LOG] Hello"15#eval maybeLog false "Hello" -- prints nothingMatch Inside Do
Pattern matching integrates naturally with do notation:
1def processResult (r : Except String Nat) : IO Unit := do2 match r with3 | .error e => IO.println s!"Error: {e}"4 | .ok n => 5 IO.println s!"Got value: {n}"6 let doubled := n * 27 IO.println s!"Doubled: {doubled}"89-- Match with ←10def processFile (path : String) : IO String := do11 let content ← IO.FS.readFile path12 match content.splitOn "\n" with13 | [] => return "empty file"14 | first :: _ => return firstEarly Exit Patterns
Combine if with return to exit a block early. This keeps control flow clear without deeply nested branches.
1def ensurePositive (n : Int) : Except String Int := do2 if n <= 0 then3 throw "expected positive" -- NOT: return .error "..."4 return n -- NOT: return .ok n56#eval ensurePositive (-1) -- Except.error "expected positive"7#eval ensurePositive 5 -- Except.ok 5do 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:
1class Monad (m : Type → Type) where2 pure : α → m α -- Wrap a value3 bind : m α → (α → m β) → m β -- Chain operationsOption, Except,List, and IO are all monads.
Combining Different Monads
You can't mix monads directly in one do block:
1-- This doesn't work:2-- def bad : IO Nat := do3-- let x ← some 5 -- Option, not IO!4-- return x56-- Convert Option to IO by handling the failure:7def good : IO Nat := do8 let opt : Option Nat := some 59 match opt with10 | none => return 011 | some x => return xUse do notation to chain two Option computations.
1def parseNat (s : String) : Option Nat := s.toNat?23def sumTwo (a b : String) : Option Nat := do4 let x ← parseNat a5 let y ← parseNat b6 return x + y78#eval sumTwo "3" "4" -- some 79#eval sumTwo "3" "x" -- none