Intermediate

I/O Operations in Haskell

Master input and output in Haskell - the IO monad, do notation, reading stdin, file reading and writing, formatted output, and I/O error handling with GHC

Input and output are where Haskell’s purity meets the messy real world. In most languages, printing a line or reading a file is just another statement. In Haskell, every action that touches the outside world is captured by a type: IO. This isn’t bureaucracy — it’s what lets the compiler guarantee that a “pure” function never secretly reads a file or prints to the screen.

Because Haskell is purely functional, a value of type IO a is not the result of an effect — it is a description of an effect that produces an a when the runtime executes it. You build these descriptions with functions like putStrLn, getLine, and readFile, then combine them using do notation or the bind operators (>> and >>=). Only the action bound to main is actually run.

This tutorial goes beyond the simple putStrLn you saw in Hello World. You’ll read input from the terminal, write and read files, format output with printf, and handle I/O errors as ordinary values. Along the way, notice how pure functions and impure actions stay cleanly separated — the type system enforces the boundary for you.

Formatted Output

Haskell offers several ways to produce output. putStr writes a string as-is, putStrLn adds a newline, and print uses a type’s Show instance. For C-style formatting, Text.Printf provides a type-safe printf.

Create a file named formatted_output.hs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import Text.Printf (printf)

main :: IO ()
main = do
    -- putStr writes without a trailing newline; putStrLn adds one
    putStr "No newline here. "
    putStrLn "Now a newline."

    -- print renders any Showable value (note the list has no spaces)
    print [1, 2, 3 :: Int]

    -- printf gives C-style, type-checked formatting
    printf "Name: %s, Age: %d\n" "Alice" (30 :: Int)
    printf "Pi is approximately %.4f\n" (3.14159265 :: Double)
    printf "Hex: %x, Zero-padded: %05d\n" (255 :: Int) (42 :: Int)

Unlike C, printf in Haskell is polymorphic in its return type: it can produce an IO () (printing) or a String (via Text.Printf.printf used as String). Here it prints, and the compiler checks that the format specifiers match the argument types.

Reading Input from stdin

getLine :: IO String reads a single line from standard input. Because it returns an IO action, you extract its result inside a do block with the <- bind arrow. To turn text into a number, use read, guided by a type annotation.

Create a file named read_input.hs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
main :: IO ()
main = do
    putStrLn "What is your name?"
    name <- getLine                 -- bind the String out of IO String

    putStrLn "How old are you?"
    ageStr <- getLine
    let age = read ageStr :: Int    -- pure parsing with a type annotation

    putStrLn ("Hello, " ++ name ++ "!")
    putStrLn ("Next year you will be " ++ show (age + 1))

Note the two binding styles: <- pulls a value out of an IO action, while let binds the result of a pure expression (no IO involved). Mixing them up is one of the most common beginner errors, and the type checker will catch it.

Writing and Reading Files

The Prelude gives you three convenient whole-file functions: writeFile (create or overwrite), appendFile (add to the end), and readFile (read the entire contents lazily). Each has an IO type, so file access is explicit in the signature.

Create a file named write_file.hs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
main :: IO ()
main = do
    -- writeFile creates the file (or overwrites it if it exists)
    writeFile "output.txt" "Line 1: Hello from Haskell\n"

    -- appendFile adds to the end without overwriting
    appendFile "output.txt" "Line 2: Appended text\n"

    -- readFile reads the whole file as a String
    contents <- readFile "output.txt"
    putStrLn "File contents:"
    putStr contents

Processing File Data with Pure Functions

The idiomatic Haskell pattern is to keep I/O at the edges and do the real work in pure functions. Here we read a file, parse it with pure list operations, then use mapM_ to run an IO action for each element.

Create a file named process_file.hs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
main :: IO ()
main = do
    -- Create a sample data file to process
    writeFile "numbers.txt" "10\n20\n30\n40\n"

    -- Read it back, then parse purely: lines splits on newlines
    contents <- readFile "numbers.txt"
    let numbers = map read (lines contents) :: [Int]

    -- All of these are pure computations over the parsed list
    putStrLn ("Numbers: " ++ show numbers)
    putStrLn ("Sum:     " ++ show (sum numbers))
    putStrLn ("Maximum: " ++ show (maximum numbers))

    -- mapM_ runs an IO action for each element, discarding results
    putStrLn "Doubled:"
    mapM_ (\n -> putStrLn ("  " ++ show (n * 2))) numbers

Notice how lines, map, read, sum, and maximum are all pure — the only impurity is the surrounding readFile, writeFile, and putStrLn. This separation makes the parsing logic trivial to test in isolation.

Handling I/O Errors

Files might not exist and disks fill up, so I/O can fail. Haskell models failures as exceptions, but Control.Exception.try lets you capture them as an ordinary Either value — turning an exception into data you can pattern match on.

Create a file named io_errors.hs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import Control.Exception (try, IOException)

main :: IO ()
main = do
    -- try wraps the action; a failure becomes a Left, success a Right
    result <- try (readFile "does_not_exist.txt") :: IO (Either IOException String)
    case result of
        Left _    -> putStrLn "Error: could not read the file (it may not exist)."
        Right txt -> putStr txt

    putStrLn "The program continues normally after handling the error."

The type annotation :: IO (Either IOException String) tells try which exception type to catch. Because the failure is now just a Left value, control flow stays explicit and the program keeps running instead of crashing.

Running with Docker

Pull the official image once, then run each example. The stdin example uses -i so the container can receive piped input.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Pull the official Haskell image
docker pull haskell:9.6

# Formatted output
docker run --rm -v $(pwd):/app -w /app haskell:9.6 runghc formatted_output.hs

# Reading input (pipe two lines into stdin; -i keeps stdin open)
printf 'Alice\n30\n' | docker run --rm -i -v $(pwd):/app -w /app haskell:9.6 runghc read_input.hs

# Writing and reading files
docker run --rm -v $(pwd):/app -w /app haskell:9.6 runghc write_file.hs

# Processing file data
docker run --rm -v $(pwd):/app -w /app haskell:9.6 runghc process_file.hs

# Handling I/O errors
docker run --rm -v $(pwd):/app -w /app haskell:9.6 runghc io_errors.hs

Expected Output

formatted_output.hs

No newline here. Now a newline.
[1,2,3]
Name: Alice, Age: 30
Pi is approximately 3.1416
Hex: ff, Zero-padded: 00042

read_input.hs (with Alice and 30 piped in)

What is your name?
How old are you?
Hello, Alice!
Next year you will be 31

write_file.hs

File contents:
Line 1: Hello from Haskell
Line 2: Appended text

process_file.hs

Numbers: [10,20,30,40]
Sum:     100
Maximum: 40
Doubled:
  20
  40
  60
  80

io_errors.hs

Error: could not read the file (it may not exist).
The program continues normally after handling the error.

Key Concepts

  • IO a is a description, not a result — building an IO action doesn’t run it; only the action wired to main (and what it calls) executes.
  • <- vs let — use <- to bind a value out of an IO action (name <- getLine) and let to name a pure expression (let age = read ageStr).
  • Purity stays at the edges — parse and compute with pure functions (lines, map, sum), and keep readFile/writeFile/putStrLn at the boundary.
  • Whole-file helperswriteFile overwrites, appendFile extends, and readFile reads lazily; all three carry IO in their types so effects are visible.
  • printf is type-safeText.Printf checks format specifiers against argument types at compile time, unlike C’s variadic version.
  • mapM_ runs an action per element — it sequences IO effects over a list and discards the results, ideal for printing each item.
  • Errors as valuestry converts an exception into an Either, letting you pattern match on failure instead of crashing.
  • Lazy readFile — the file is read on demand as the resulting String is consumed, which is memory-friendly for large files but means the handle stays open until fully read.

Running Today

All examples can be run using Docker:

docker pull haskell:9.6
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining