I/O
- Understand the
IOtype and how Lean separates pure computation from side effects - Sequence IO actions with
doblocks and←(left arrow) - Capture results from IO actions and chain them into larger programs
- Read from standard input and print to standard output
- Every program eventually has to talk to the world
- But pure functions always return the same result for the same arguments, so how do we handle something like reading from a file?
- The answer is complex in theory but simple(r) in practice
What Is IO?
- A function that does I/O has
IOin its return typeStringmeans "a string, computed purely"IO Stringmeans "an action that, when run, produces a string"- This pattern's formal name is a monad
- The compiler enforces that I/O only happens where you say it does
- Pure functions are guaranteed not to have side effects
- You can test and reason about them without worrying about hidden state
- This is like Python's
async def: an async function must return anAwaitable- You cannot accidentally run an async function from sync code
- Lean's
IOtype is the same idea: the compiler prevents I/O from leaking into pure functions
- Separately,
doand←(left arrow) are how you sequence effectful actions- Type
\leftarrowin the editor - This is the monad part: chaining steps where each depends on the last
- Like
awaitlets you write sequential-looking code asynchronously,←does the same for IO
- Type
- You do not need to understand the theory of monads to use
IOin Lean- But we describe it briefly in an appendix
Hello World
i
-- Every Lean program starts with main
def main : IO Unit :=
IO.println "Hello, world!"
#eval main
i
warning: failed to query latest release, using existing version 'leanprover/lean4:v4.30.0'
Hello, world!
mainis the entry point for Lean programs- Like Python's
if __name__ == "__main__":block
- Like Python's
IO Unitis the return type:IOmeans "does input/output",Unitmeans "no useful return value"Unitis like Python'sNone: the side effects are the point
IO.printlnprints a string followed by a newline- Newline plus indentation isn't required, but it helps
#eval mainruns the action during compilation so we can see the output- Lean's pure functions cannot raise exceptions
Doing More Than One Thing
i
-- Use `do` to run IO actions one after another
def main : IO Unit := do
IO.println "Step 1: start"
IO.println "Step 2: middle"
IO.println "Step 3: done"
#eval main
i
warning: failed to query latest release, using existing version 'leanprover/lean4:v4.30.0'
Step 1: start
Step 2: middle
Step 3: done
dogroups a sequence of IO actions into one- Statements run top to bottom
- Without
do, we can only write one IO action per function body - Each line in a
doblock runs in order and must returnIO Unit- Unless we capture it: more on that shortly
- If your function body is a single IO action, no
dois needed - If you want two or more actions in sequence, wrap them in
do ;can be used as an alternative separator insidedoblocks- E.g.,
IO.println "Hi"; IO.println "there"is equivalent to two lines - Generally avoided to keep code readable
- E.g.,
Forgetting do
i
-- Forgetting `do` is the most common IO mistake
def main : IO Unit :=
IO.println "Starting..."
IO.println "Done!"
#eval main
i
warning: failed to query latest release, using existing version 'leanprover/lean4:v4.30.0'
missing_do_err.lean:4:2: error: Application type mismatch: The argument
IO.println
has type
?m.3 → IO Unit
but is expected to have type
Void IO.RealWorld
in the application
IO.println "Starting..." IO.println
missing_do_err.lean:6:0: error: Aborting evaluation since the expression depends on the 'sorry' axiom, which can lead to runtime instability and crashes.
To attempt to evaluate anyway despite the risks, use the '#eval!' command.
- Forgetting
dois the most commonIOmistake - Without it, Lean tries to use the second
IO.printlnas an argument to the first- That's why the error message mentions "application type mismatch"
- The fix: add
doafter:=
Pure Values Inside do
i
-- `let x := expr` binds a pure value inside a do block
def greet (name : String) : String :=
s!"Hello, {name}!"
def main : IO Unit := do
let msg := greet "Lean"
IO.println msg
#eval main
i
warning: failed to query latest release, using existing version 'leanprover/lean4:v4.30.0'
Hello, Lean!
let x := exprbinds a pure (non-IO) value inside adoblock- Like Python's
x = exprinside an async function
- Like Python's
greetis a plain function that returns aString(no I/O)- You can call pure functions freely inside
do- Only the final
IO.printlndoes I/O
- Only the final
- The compiler will reject
let msg ← greet "Lean"becausegreetdoes not return anIO let _ := expris the discard pattern- It binds a value but throws it away
- The underscore means "I don't intend to use this"
- It also suppresses the unused-variable warning from the compiler
- Be careful:
let _ := someIOActionstores the action without running it- Use
←for IO
- Use
Functions That Return IO Actions
i
-- Functions can return IO actions, not just plain values
def printTwice (msg : String) : IO Unit := do
IO.println msg
IO.println msg
def main : IO Unit := do
printTwice "echo!"
printTwice "again"
#eval main
i
warning: failed to query latest release, using existing version 'leanprover/lean4:v4.30.0'
echo!
echo!
again
again
- A function can return an
IO Unit: a reusable IO action- Like Python's
async def, calling it gives you something you still have to run
- Like Python's
printTwice "echo!"runs twoIO.printlncalls in sequenceIOfunctions compose:maincallsprintTwicetwice
Capturing Results from IO
i
-- Use ← to capture the result of an IO action
def makeGreeting : IO String := do
IO.println "Building greeting..."
return "Hello from IO!"
def main : IO Unit := do
let msg ← makeGreeting
IO.println msg
#eval main
i
warning: failed to query latest release, using existing version 'leanprover/lean4:v4.30.0'
Building greeting...
Hello from IO!
- Use
let x ← ioAction(the left-arrow←) to capture the result of an IO action- Like Python's
x = await some_coroutine()
- Like Python's
makeGreetinghas typeIO String: it is an action that produces aStringreturn exprwraps a pure value as an IO result- This is not the same as Python's
return, which exits the function immediately - Lean's
returnstays in thedoblock: it just marks a value as an IO result - Yes, this is confusing…
- This is not the same as Python's
let msg ← makeGreetingruns the action and binds the resultingStringtomsg- Two binding forms to remember:
let x := expris a pure expression, no IOlet x ← actionis an I/O action, captures its result
Reading Input
i
-- IO.getStdin gives access to standard input
def main : IO Unit := do
IO.println "Enter your name:"
let stdin ← IO.getStdin
let name ← stdin.getLine
IO.println s!"Hello, {name.trimAscii}!"
#eval main
i
warning: failed to query latest release, using existing version 'leanprover/lean4:v4.30.0'
Enter your name:
Hello, !
IO.getStdinreturns anIO.FS.Streamconnected to standard inputstdin.getLinereads one line and returns it as anIO String- Both require
←because they are IO actions that produce values String.trimAsciiremoves leading and trailing whitespace, including the trailing newline- Without
.trimAscii,getLinereturns the newline character as part of the string - This would make
s!"Hello, {name}!"print an unwanted line break
- Without
- The output above shows an empty name because
#evalruns inside the Lean compiler process- The compiler claims stdin for its own use, so
stdin.getLinereturns an empty string - This is a limitation of
#eval, not of Lean's IO system - In the next lesson we will compile and run programs as proper executables, where stdin works correctly
- The compiler claims stdin for its own use, so
Chaining IO Actions
i
-- Chain multiple ← binds in a single do block
def main : IO Unit := do
IO.println "Enter your first name:"
let stdin ← IO.getStdin
let first ← stdin.getLine
IO.println "Enter your last name:"
let last ← stdin.getLine
IO.println s!"Hello, {first.trimAscii} {last.trimAscii}!"
#eval main
i
warning: failed to query latest release, using existing version 'leanprover/lean4:v4.30.0'
Enter your first name:
Enter your last name:
Hello, !
doblocks compose naturally: you can chain as many←binds as you need- Each
←runs the action and captures its result for the next step - Here
getLineis called twice: once for the first name, once for the last name- Both captures are combined in a single
IO.println
- Both captures are combined in a single
- Like writing multiple
awaitcalls in oneasyncfunction
For Loops in do Blocks
i
-- Iterate over a list in a do block with for
def main : IO Unit := do
let fruits := ["apple", "banana", "cherry"]
for fruit in fruits do
IO.println fruit
#eval main
i
warning: failed to query latest release, using existing version 'leanprover/lean4:v4.30.0'
apple
banana
cherry
for x in list doiterates over a list inside adoblock- Like Python's
for x in list:but the body is anIOaction
- Like Python's
- Each iteration runs in sequence, top to bottom
- Works with
List,Array, and any type that implementsForIn- Including the results of
IO.FS.readDir(used in archive)
- Including the results of
- The loop is syntactic sugar for recursion: there is no mutable loop variable by default
For Loops Elsewhere
forwithlet mutalso works in pure functions that do not involveIO- Lean implicitly uses the identity monad and compiles the loop to a pure fold
- The syntax is identical:
for x in xs do body
i
-- for with let mut works in pure functions, not just IO do blocks
def countPositive (xs : List Int) : Nat :=
let mut count := 0
for x in xs do
if x > 0 then count := count + 1
count
#eval countPositive [-3, 1, -1, 4, 1, 5, -9, 2, -6]
i
5
Accumulating Results with for
i
-- Accumulate a result across loop iterations using let mut
def main : IO Unit := do
let words := ["hello", "world", "from", "Lean"]
let mut count := 0
for w in words do
if w.length > 4 then
count := count + 1
IO.println s!"Words longer than 4 chars: {count}"
#eval main
i
warning: failed to query latest release, using existing version 'leanprover/lean4:v4.30.0'
Words longer than 4 chars: 2
let mut count := 0declares a mutable variable in adoblock- Only valid inside
do: pure functions cannot usemut
- Only valid inside
count := count + 1updates it- Without
:=, the variable is read-only
- Without
- Like Python's
count = 0; for w in words: if …: count += 1 - Lean guarantees
mutvariables don't escape thedoblock
What let mut Really Does
i
-- let mut looks like mutation but compiles to state-passing under the hood
-- The compiler rewrites mutable variables into function arguments
def countEvens (xs : List Int) : IO Nat := do
let mut count := 0
for x in xs do
if x % 2 == 0 then
count := count + 1
return count
-- The same logic as a pure function using foldl (no IO, no mutation)
def countEvensPure (xs : List Int) : Nat :=
xs.foldl (fun acc x => if x % 2 == 0 then acc + 1 else acc) 0
#eval countEvens [1, 2, 3, 4, 5, 6]
#eval countEvensPure [1, 2, 3, 4, 5, 6]
i
warning: failed to query latest release, using existing version 'leanprover/lean4:v4.30.0'
3
3
let mutis syntactic sugar- The compiler rewrites mutable variables as extra function arguments threaded through each step
- The generated code is purely functional: data structures are not mutated in place
- This is why pure functions can remain efficient even without mutable state
- The compiler handles the bookkeeping
- The pure
foldlversion and thelet mutloop produce the same result- For straightforward accumulation, the pure form is often shorter
- For complex logic with multiple conditions or early exit,
let mutis usually easier to read
- Both approaches have the same run time
Summary
doand←let you chain IO actions in sequence- The practical rules are:
- Use
doto sequence actions - Use
let x ← actionto capture an IO result - Use
let x := exprfor pure computation - Use
return exprto produce an IO result from a pure value
- Use
- Lean's
IOis more than just files and sockets - It also manages random number generation and any other effectful computation
Errors Without Exceptions
i
-- Except carries either a success value or an error message
def safeDivide (a b : Int) : Except String Int :=
if b == 0 then Except.error "division by zero"
else Except.ok (a / b)
#eval match safeDivide 10 2 with
| Except.ok n => s!"result: {n}"
| Except.error e => s!"error: {e}"
#eval match safeDivide 10 0 with
| Except.ok n => s!"result: {n}"
| Except.error e => s!"error: {e}"
i
warning: failed to query latest release, using existing version 'leanprover/lean4:v4.30.0'
"result: 5"
"error: division by zero"
Except String Intis a sum type with two constructors:Except.ok n: success, carrying a value of typeIntExcept.error e: failure, carrying a message of typeString
- Like the
(value, error)convention of other languages, but the compiler forces you to check both cases matchon anExceptis exhaustive: you cannot forget the error case- Use
Exceptfor pure functions that can fail with a reason- Use
Optionwhen absence is normal and no explanation is needed
- Use
I/O Can Fail
i
-- try/catch wraps IO actions that might fail
def readFileSafe (path : String) : IO (Option String) := do
try
let content ← IO.FS.readFile path
return some content
catch _ =>
return none
def main : IO Unit := do
-- hello_world.lean exists in this directory
match ← readFileSafe "hello_world.lean" with
| some _ => IO.println "File found"
| none => IO.println "File not found"
-- no_such_file.txt does not exist
match ← readFileSafe "no_such_file.txt" with
| some _ => IO.println "Unexpected success"
| none => IO.println "Missing file handled gracefully"
#eval main
i
warning: failed to query latest release, using existing version 'leanprover/lean4:v4.30.0'
File found
Missing file handled gracefully
try action catch _ => fallbackcatches any IO exception- The
_discards the exception value - Inspect it with
catch e => ...if needed
- The
- IO operations like
IO.FS.readFilethrow on failure (file not found, permission denied, etc.) - Wrapping in
try/catchconverts an exception into a safeOptionorExceptvalue - Like Python's
try: ... except OSError: ...but typed- The return type shows the fallback
- Pure
Except(from the previous section) and IOtry/catchare complementary:- Pure functions use
Exceptto report errors without side effects - IO functions use
try/catchto handle runtime failures
- Pure functions use
Three Ways to Do It
Lean splits failure across three mechanisms where Python uses exceptions for all of them:
-
Use
Optionin pure functions when absence is normal and no explanation needed, e.g., looking up a key that may not exist. -
Use
Except e ain pure functions when failure needs a reason, e.g., parsing a string that may be malformed. -
Use
try/catchin IO actions when runtime failure outside your control, e.g., file not found, or a network error. -
OptionandExceptappear in function signatures, so the compiler forces callers to handle failure try/catchis only available inIO: you cannot silently ignore an IO exception- When migrating from Python:
- Replace
Nonereturns withOption - Replace
ValueError/KeyErrorin pure logic withExcept - Replace
OSError/IOErrorwithtry/catcharound IO actions
- Replace
Exercises
Fix: Missing do
i
-- This should print two lines but has a bug. Fix it.
def main : IO Unit :=
IO.println "Hello"
IO.println "World"
#eval main
hint
- Without
do, Lean treats the body as a single expression - The second
IO.printlnlooks like an argument to the first
Fix: Wrong Left Arrow
i
-- Should print the greeting, but has a bug. Fix it.
def makeMsg : IO String := return "Hello, Lean!"
def main : IO Unit := do
let msg := makeMsg
IO.println msg
#eval main
hint
makeMsgreturnsIO String, notStringlet msg := makeMsgstores the IO action itself rather than running it
Fix: Missing return
i
-- This function should return a greeting, but has a bug. Fix it.
def getGreeting : IO String := do
let msg := "Hello!"
msg
def main : IO Unit := do
let g ← getGreeting
IO.println g
#eval main
hint
- The function body is
IO Stringbutmsghas typeString - Lean needs
return msgto lift the pure value intoIO
Fix: Wrong Return Type
i
-- This function should print a message, but its return type is wrong. Fix it.
def announce (item : String) : IO String := do
IO.println s!"Announcing: {item}"
def main : IO Unit := do
announce "Lean 4"
#eval main
hint
announceprints something and returns nothing useful- Its return type should be
IO Unit, notIO String
Fix: Arrow on a Pure Expression
i
-- Should print the doubled value, but has a bug. Fix it.
def double (n : Int) : Int := n * 2
def main : IO Unit := do
let result ← double 5
IO.println s!"Result: {result}"
#eval main
hint
double 5is a pure function call: it returnsInt, notIO Int←is for IO actions;double 5is not one
Fix: Silently Discarded IO Action
i
-- Should print both lines, but only prints one. Fix it.
def main : IO Unit := do
IO.println "Step 1: start"
let _ := IO.println "Step 2: done"
#eval main
hint
let _ := IO.println "Step 2: done"stores the IO action as a value — it never runs- The underscore (
_) means "I don't intend to use this binding" - For an IO action, you want
←(run it) or plainIO.println(no binding at all)
Write: Two-Line Greeting
i
-- Add a second line so main prints "Hello, Lean!" then "Ready to go!"
def main : IO Unit := do
IO.println "Hello, Lean!"
#eval main
hint
- Add a second
IO.printlncall inside thedoblock - The text should be
"Ready to go!" - Both lines are plain
IO.printlncalls with no←
Write: Print in Uppercase
i
-- Fix printLoud so it prints the message in uppercase.
-- Hint: String.toUpper converts a String to all caps.
def printLoud (msg : String) : IO Unit :=
IO.println msg
def main : IO Unit := do
printLoud "hello"
printLoud "world"
#eval main
hint
- Replace
IO.println msgwithIO.println msg.toUpper String.toUpper(or.toUpper) converts a string to all capitals- The body is a single expression, so no
doneeded
Write: Build a Message
- Use
++to concatenate strings
i
-- Fix buildMsg so it returns "Message: " ++ text (using return).
def buildMsg (text : String) : IO String :=
return text
def main : IO Unit := do
let msg ← buildMsg "ready"
IO.println msg
#eval main
hint
- The function should return
"Message: " ++ text - Use
return ("Message: " ++ text)to lift the pure result intoIO - When you fix it,
mainshould printMessage: ready
Write: Use a Pure Function in IO
i
-- Fix main so it computes and prints the sum of the list (should be 15).
def addUp (xs : List Int) : Int := xs.foldl (· + ·) 0
def main : IO Unit := do
IO.println "Sum is 0"
#eval main
hint
- Call
addUp [1, 2, 3, 4, 5]withlet total := ...(plainlet, not←) - Then use
totalinsides!"Sum is {total}"in theIO.printlncall - The output should be
Sum is 15
Write: Repeat Three Times
i
-- Fix repeat3 so it calls IO.println with msg exactly three times.
def repeat3 (msg : String) : IO Unit := do
IO.println msg
def main : IO Unit := do
repeat3 "again"
#eval main
hint
- Add
doto the body ofrepeat3and callIO.println msgthree times - Each call is a separate line in the
doblock - When fixed,
mainshould printagainthree times
Write: Read and Greet
i
-- Fix main to read a name from stdin and print "Hello, <name>!"
-- instead of always printing "Hello, stranger!"
def main : IO Unit := do
let stdin ← IO.getStdin
let _name ← stdin.getLine
IO.println "Hello, stranger!"
#eval main
hint
_nameuses_(underscore prefix) to suppress the unused-variable warning- Lean warns you when a variable is bound but never used
- Prefixing with
_tells Lean "I'm keeping this binding but not using it yet"
- Change
_nametonameso the binding is used - Replace
"Hello, stranger!"withs!"Hello, {name.trimAscii}!"getLinereturns the line with the trailing newline- Use
.trimAsciito remove it before printing