Polyglot
Languages Haskell io
Haskell § io

IO

The IO monad is Haskell’s mechanism for effectful computation — input, output, mutation, concurrency, and any operation that interacts with the world outside the program. The type system tracks IO explicitly: a value of type IO a is a description of an effectful computation that, when run, produces an a. Pure code cannot perform I/O without the IO monad’s permission; the segregation is one of Haskell’s distinguishing features and the foundation of the language’s reasoning guarantees.

This page covers the IO type, the principal IO operations, the do-notation for sequencing, file and console I/O, the relationship between IO and pure code, and lazy I/O and its hazards.

The IO type

A value of type IO a is conceptually an action — a description of effectful computation:

main :: IO ()                       -- a top-level action that produces ()
putStrLn :: String -> IO ()          -- print a string and a newline
getLine :: IO String                 -- read a line from stdin
readFile :: FilePath -> IO String     -- read a file
writeFile :: FilePath -> String -> IO ()    -- write a file

The IO type is parameterised by the result type. IO () is an action with no useful result (the unit value); IO String produces a String; IO Int produces an Int.

A value of type IO a is not an a. The IO wrapper is the type-level mark that the value involves I/O; pure code cannot inspect or unwrap it. The only way to get the a out is to be in another IO context (the do-notation’s <- operator).

-- Pure:
greet :: String -> String
greet name = "Hello, " ++ name

-- Effectful (in IO):
main :: IO ()
main = do
    name <- getLine          -- name :: String
    putStrLn (greet name)

main as the entry point

A Haskell program’s entry point is main, which must have type IO ():

main :: IO ()
main = putStrLn "Hello, world!"

The runtime invokes main to start the program; the actions described by main are then executed.

The do-notation

The do-notation sequences IO actions:

main :: IO ()
main = do
    putStrLn "What is your name?"
    name <- getLine
    putStrLn ("Hello, " ++ name)

The block contains a sequence of statements, each an IO action. The <- binds the result of an action to a name; the next line in the block runs after the previous one.

The do-notation desugars to >>= (monadic bind):

main = putStrLn "What is your name?"
    >> getLine
    >>= \name -> putStrLn ("Hello, " ++ name)

The full treatment of >>= and the monad type class is in Monads.

The let admits pure bindings within a do-block:

main = do
    line <- getLine
    let upper = map toUpper line
        len   = length upper
    putStrLn upper
    print len

The let introduces pure bindings; no <- is needed because no effectful action is performed.

Console I/O

The principal console operations:

putStr :: String -> IO ()           -- print without newline
putStrLn :: String -> IO ()          -- print with newline
print :: Show a => a -> IO ()        -- = putStrLn . show

getLine :: IO String                 -- read a line
readLn :: Read a => IO a              -- read and parse
getContents :: IO String              -- read entire stdin

interact :: (String -> String) -> IO ()   -- transform stdin to stdout

getContents reads all of stdin lazily; the contents are produced as the program reads them. interact is the conventional one-liner for stdin-to-stdout filters:

main :: IO ()
main = interact (unlines . map reverse . lines)
-- reverses every line of input

The implementation of interact is getContents >>= putStr . f; the laziness admits processing arbitrarily large inputs.

File I/O

The conventional file operations:

readFile :: FilePath -> IO String                    -- read entire file (lazy!)
writeFile :: FilePath -> String -> IO ()              -- write entire file
appendFile :: FilePath -> String -> IO ()              -- append

readFile returns lazily — the file is held open until the contents are consumed. The mechanism admits processing very large files but introduces the lazy I/O hazard (treated below).

For more control, the Handle-based API in System.IO:

import System.IO

main = do
    handle <- openFile "input.txt" ReadMode
    contents <- hGetContents handle
    -- ... process contents ...
    hClose handle

The bracket pattern from Control.Exception admits exception-safe cleanup:

import Control.Exception
import System.IO

main = bracket
    (openFile "input.txt" ReadMode)
    hClose
    (\handle -> do
        contents <- hGetContents handle
        process contents)

The conventional contemporary advice is to use withFile from System.IO:

import System.IO

main = withFile "input.txt" ReadMode $ \handle -> do
    contents <- hGetContents handle
    process contents

withFile opens the file, runs the action, and closes the file (even on exception). The pattern is the conventional Haskell idiom for file I/O.

For Text and ByteString I/O:

import qualified Data.Text.IO as TIO
import qualified Data.ByteString as BS

text <- TIO.readFile "input.txt"
bytes <- BS.readFile "input.bin"

For most non-trivial I/O, Data.Text.IO (with strict Text) is preferable to readFile (with String). The text package’s I/O routines also admit explicit charsets:

import qualified Data.Text.IO as TIO
import qualified Data.Text.Encoding as TE
import qualified Data.ByteString as BS

text <- TIO.readFile "utf8.txt"            -- assumes UTF-8

-- Or with explicit encoding:
bytes <- BS.readFile "data.bin"
let text = TE.decodeUtf8 bytes

Handle and the I/O abstractions

A Handle represents an open file or stream:

import System.IO

stdin   :: Handle
stdout  :: Handle
stderr  :: Handle

hGetLine   :: Handle -> IO String
hPutStr    :: Handle -> String -> IO ()
hPutStrLn  :: Handle -> String -> IO ()
hClose     :: Handle -> IO ()
hIsEOF     :: Handle -> IO Bool
hFlush     :: Handle -> IO ()
hGetContents :: Handle -> IO String   -- lazy read of the rest

The standard handles stdin, stdout, stderr are pre-opened. The conventional patterns for explicit handles:

import System.IO

main = do
    handle <- openFile "log.txt" AppendMode
    hPutStrLn handle "an entry"
    hClose handle

-- Or with bracket:
withFile "log.txt" AppendMode $ \h -> hPutStrLn h "an entry"

The withFile form is the conventional contemporary choice; it guarantees the file is closed.

Modes and buffering

openFile admits several modes:

data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode

openFile :: FilePath -> IOMode -> IO Handle

Buffering may be controlled:

hSetBuffering :: Handle -> BufferMode -> IO ()

data BufferMode
    = NoBuffering
    | LineBuffering
    | BlockBuffering (Maybe Int)

The conventional discipline is to leave the default buffering alone unless measurement justifies changing it.

IORef for mutable references

Haskell admits mutable references in IO through IORef:

import Data.IORef

newIORef :: a -> IO (IORef a)
readIORef :: IORef a -> IO a
writeIORef :: IORef a -> a -> IO ()
modifyIORef :: IORef a -> (a -> a) -> IO ()
modifyIORef' :: IORef a -> (a -> a) -> IO ()    -- strict variant

main = do
    ref <- newIORef 0
    modifyIORef' ref (+ 1)
    n <- readIORef ref
    print n

IORef admits mutable state in IO; it is the conventional choice for stateful IO operations.

For atomic updates: atomicModifyIORef, atomicModifyIORef'. For thread-safe shared state across threads: MVar, TVar (treated in Concurrency).

The conventional advice is to prefer the State monad in pure code (treated in Monads) and IORef only when the state is genuinely tied to IO operations.

Lazy I/O and its hazards

The readFile and getContents functions return lazily — the contents are produced as they are consumed. The mechanism admits processing arbitrarily large files in constant memory:

main = do
    contents <- readFile "huge.txt"
    let firstLine = head (lines contents)
    putStrLn firstLine
-- only the first line is read; the rest of the file is not loaded

The mechanism has substantial hazards:

File handles held open

main = do
    contents <- readFile "log.txt"
    -- ... process some of contents ...
    return ()
-- the file may be held open until contents is GC'd

The handle is held until the lazy String is fully consumed (or garbage-collected). The mechanism produces “too many open files” errors in long-running programs that read many files.

Race conditions with concurrent file modification

contents <- readFile "data.txt"
-- if data.txt is modified concurrently, contents may reflect a mix
-- of old and new content

Order-of-evaluation surprises

main = do
    contents <- readFile "input.txt"
    writeFile "output.txt" (reverse contents)
-- The two operations may interact in unexpected ways:
--   if input.txt and output.txt are the same file, the result is undefined.

The conventional defences

  • Use strict I/O — read the entire file into a strict Text or ByteString:
import qualified Data.Text.IO as TIO
import qualified Data.ByteString as BS

contents <- TIO.readFile "input.txt"        -- strict; entire file loaded
bytes <- BS.readFile "input.bin"              -- strict
  • Use the Handle-based API with explicit reading:
withFile "input.txt" ReadMode $ \h -> do
    line1 <- hGetLine h
    line2 <- hGetLine h
    process line1 line2
  • Use hClose explicitly when not using withFile or bracket.

  • For streaming, use conduit or pipes libraries — they provide explicit-ordering streaming abstractions that avoid the lazy-I/O hazards.

The relationship between IO and pure code

A function with type String -> Int is pure: it cannot perform I/O. A function with type String -> IO Int is effectful: it may perform I/O.

The type system enforces the separation:

greet :: String -> String           -- pure
greet name = "Hello, " ++ name

main :: IO ()                        -- effectful (in IO)
main = do
    name <- getLine                   -- bind effectful result
    putStrLn (greet name)            -- pass to pure function

Pure functions can be called from IO; IO actions cannot be called from pure code without being lifted to IO. The mechanism admits substantial benefit:

  • Pure code is testable — same inputs yield same outputs; no fixtures, no mocks.
  • Pure code is compositional — functions compose freely.
  • The type signature documents effects — a function’s type tells whether it performs I/O.

The conventional Haskell discipline is to write the substantial majority of code as pure functions and to push I/O to the boundaries of the program. The IO-typed code typically reads input, applies a pure transformation, and writes output:

main = do
    input <- readFile "input.txt"
    let result = process input         -- the actual work, pure
    writeFile "output.txt" result

The pattern is the conventional Haskell architecture for batch programs.

Common patterns

Read, process, write

main = do
    contents <- readFile "input.txt"
    let processed = map toUpper contents
    writeFile "output.txt" processed

Iterate over IO actions

main = do
    forM_ [1..10] $ \n -> do
        putStrLn ("Item " ++ show n)
        process n

forM_ (and mapM_) admit iterating effectfully over a list, discarding the results. The _-suffixed forms are conventional for “for side effect only”.

Collect IO results

main = do
    contents <- mapM readFile ["a.txt", "b.txt", "c.txt"]
    print (length contents)            -- 3

mapM collects the results into a list; mapM_ discards them.

Conditional IO

import Control.Monad (when, unless)

main = do
    args <- getArgs
    when (length args == 0) $ do
        putStrLn "usage: prog <file>"
        exitFailure
    let file = head args
    contents <- readFile file
    process contents

when/unless admit conditional effects without an explicit if/then/else returning () and ().

Try-catch (exception handling)

import Control.Exception

main = do
    result <- try (readFile "input.txt") :: IO (Either IOException String)
    case result of
        Right contents -> process contents
        Left exception -> putStrLn ("Error: " ++ show exception)

The full treatment of exceptions is in Error handling.

Resource cleanup

import Control.Exception (bracket)
import System.IO

main = bracket
    (openFile "log.txt" AppendMode)
    hClose
    (\h -> do
        hPutStrLn h "an entry"
        hPutStrLn h "another entry")

bracket ensures the resource is released even on exception. withFile is bracket for files.

IORef for stateful loops

import Data.IORef

main = do
    counter <- newIORef 0
    forM_ [1..1000] $ \_ -> do
        modifyIORef' counter (+ 1)
    n <- readIORef counter
    print n

The pattern admits explicit accumulators in IO contexts; modern alternatives include State over IO via StateT.

Async and parallel I/O

For concurrent I/O, the async library:

import Control.Concurrent.Async

main = do
    a1 <- async (fetch "url1")
    a2 <- async (fetch "url2")
    a3 <- async (fetch "url3")
    [r1, r2, r3] <- mapM wait [a1, a2, a3]
    -- or:
    [r1, r2, r3] <- mapConcurrently fetch ["url1", "url2", "url3"]

The full treatment is in Concurrency.

A note on the IO discipline

The IO monad is one of Haskell’s most distinctive features. The conventional contemporary advice:

  • Use strict Text and ByteString I/O for non-trivial work; lazy I/O is convenient but produces hazards.
  • Use withFile/bracket for resource management; never rely on the GC for file handles.
  • Push pure transformations into pure functions; IO-typed code should be the boundary, not the body.
  • Use mapM/forM/mapM_/forM_ for effectful iteration.
  • For substantial concurrent I/O, the async library is the conventional choice.

The discipline of writing IO-correct Haskell is the discipline of segregating pure from effectful code, managing resources explicitly, and using the type system to track which operations have side effects. The combination is one of the language’s principal contributions to programming.