The IO Monad
IO encapsulates side effects—file I/O, network requests, randomness—while keeping pure functions pure.
Why IO?
Pure functions always return the same output for the same input. But some operations are inherently impure:
- Reading user input (different each time)
- Writing to a file (changes external state)
- Getting current time (depends on when called)
- Making HTTP requests (network conditions vary)
The IO type marks functions that perform these effects:
1-- Pure: no IO in type2def add (x y : Nat) : Nat := x + y34-- Impure: returns IO5def greet : IO Unit := IO.println "Hello!"67-- The type tells you: this function does I/OIO, it might perform side effects. If it doesn't, it's guaranteed pure.Basic IO Operations
Console I/O
Console input and output are the most common IO operations. Use IO.printlnto write a line, IO.print to write without a newline, and(← IO.getStdin).getLine to read user input.
1-- Print to console2def sayHello : IO Unit := do3 IO.println "Hello, World!"45-- Read from console. Note: there is no bare IO.getLine —6-- you read from a stream, and stdin is one.7def askName : IO String := do8 IO.print "Enter your name: "9 (← IO.getStdin).getLine1011-- Put it together12def interactive : IO Unit := do13 IO.print "What's your name? "14 let name ← (← IO.getStdin).getLine15 IO.println s!"Hello, {name.trimAscii}!"File I/O
Reading and writing files is straightforward with the IO.FSnamespace. For simple cases, readFile and writeFilehandle the entire file at once.
1-- Read entire file2def readConfig : IO String := do3 IO.FS.readFile "config.txt"45-- Write to file6def writeLog (msg : String) : IO Unit := do7 IO.FS.writeFile "log.txt" msg89-- Append to file10def appendLog (msg : String) : IO Unit := do11 let handle ← IO.FS.Handle.mk "log.txt" .append12 handle.putStrLn msgCommand Line Arguments
When your Lean program is compiled and run from the command line, arguments passed to the executable are available as a list of strings in the main function.
1def main (args : List String) : IO Unit := do2 IO.println s!"Got {args.length} arguments"3 for arg in args do4 IO.println s!" - {arg}"Composing IO Actions
1-- Sequence with do notation2def program : IO Unit := do3 IO.println "Step 1"4 IO.println "Step 2"5 IO.println "Step 3"67-- Pass results between actions8def compute : IO Nat := do9 IO.println "Computing..."10 let input ← (← IO.getStdin).getLine11 let n := input.trimAscii.toString.toNat!12 IO.println s!"Got {n}"13 return n * 2Keep a Pure Core
A common pattern is to keep business logic pure and use IO only for input/output. This keeps code testable and reusable.
1-- Pure function2def sanitize (s : String) : String := s.trimAscii.toString.toLower34-- IO wrapper5def greetUser : IO Unit := do6 IO.print "Name: "7 let name ← (← IO.getStdin).getLine8 let clean := sanitize name9 IO.println s!"Hello, {clean}!"Error Handling in IO
IO operations can fail. Use try/catch:
1def safeRead (path : String) : IO String := do2 try3 IO.FS.readFile path4 catch e =>5 IO.println s!"Error reading file: {e}"6 return ""78-- Or return an option/result9def tryReadFile (path : String) : IO (Option String) := do10 try11 let content ← IO.FS.readFile path12 return some content13 catch _ =>14 return noneExcept, IO exceptions are actual runtime exceptions. Use try/catch to handle them.The main Function
Every Lean executable has a main function:
1-- Simplest form2def main : IO Unit := do3 IO.println "Hello, World!"45-- With command line arguments6def main (args : List String) : IO Unit := do7 match args with8 | [] => IO.println "No arguments"9 | _ => IO.println s!"Arguments: {args}"1011-- With exit code12def main : IO UInt32 := do13 IO.println "Running..."14 return 0 -- 0 = success1516-- (These are three ALTERNATIVES — a file may only define main once.)IO Unit, IO UInt32, and either of those taking List String. With IO Unit the exit code is 0 unless an uncaught exception escapes, in which case it is 1. Use IO UInt32 when you need to signal specific failures to a shell script or CI job.IO, BaseIO, and EIO
IO is not primitive — it is one member of a small family, and you will see the others in signatures and error messages.
1-- EIO ε α : an IO action that can fail with an error of type ε2-- IO α = EIO IO.Error α ← the usual one3-- BaseIO α = EIO Empty α ← cannot fail at all45#check @IO.getStdin -- BaseIO IO.FS.Stream — reading stdin never fails6#check @IO.FS.readFile -- ... : IO String — reading a file can78-- A BaseIO action can be used anywhere an IO action is expected9-- (it just never takes the failure path), but not the other way round.try/catchgives you "no exceptions to catch"-flavoured type errors, check whether the action is BaseIO — it is telling you the failure you are guarding against cannot happen.Running External Commands
1-- Run a command and capture its output2#eval show IO Unit from do3 let out ← IO.Process.output { cmd := "echo", args := #["hello"] }4 IO.println s!"exit={out.exitCode} stdout={out.stdout.trimAscii}"56-- Run and just wait for the exit code7#eval show IO Unit from do8 let code ← IO.Process.spawn { cmd := "echo", args := #["hi"] } >>= (·.wait)9 IO.println s!"exited with {code}"Useful IO Utilities
1-- Get current time2def showTime : IO Unit := do3 let now ← IO.monoMsNow4 IO.println s!"Milliseconds since start: {now}"56-- Environment variables7def getEnv : IO Unit := do8 let path ← IO.getEnv "PATH"9 match path with10 | none => IO.println "PATH not set"11 | some p => IO.println s!"PATH = {p}"1213-- Current working directory14def showCwd : IO Unit := do15 let cwd ← IO.currentDir16 IO.println s!"Current directory: {cwd}"1718-- List directory contents (readDir lives on FilePath, not IO.FS)19def listDir : IO Unit := do20 let entries ← (System.FilePath.mk ".").readDir21 for entry in entries do22 IO.println entry.fileNameDeep Dive: IO is Lazy
An IO value is a description of an action, not the action itself. The action only runs when the runtime executes it.
1-- This doesn't print anything:2def action : IO Unit := IO.println "Hello"34-- It's just a value. The printing happens when main runs:5def main : IO Unit := actionThis is why you can pass IOactions around, store them in data structures, and compose them—they're just values.
Mixing Pure and Impure Code
1-- Pure function2def double (n : Nat) : Nat := n * 234-- Use pure functions inside IO5def pureInIO : IO Unit := do6 let x := double 21 -- Pure computation7 IO.println s!"Result: {x}"89-- But you can't use IO inside pure functions10-- def bad : Nat := do11-- let x ← (← IO.getStdin).getLine -- Error! Can't do IO here12-- x.lengthIOonly at the boundaries—reading input, writing output.Running IO in #eval
1-- #eval can run IO actions2#eval IO.println "This prints in the editor"34-- Useful for quick tests5#eval do6 IO.println "Line 1"7 IO.println "Line 2"8 return 42Read a line from input, parse it as a Nat, and print double the value.
1def readDouble : IO Unit := do2 IO.print "Enter a number: "3 let input ← (← IO.getStdin).getLine4 let n := input.trimAscii.toString.toNat!5 IO.println s!"Double is {n * 2}"