The IO Monad
In most languages, calling a function that prints to the console is no different from calling one that adds two numbers. Both run, both maybe return a value, both can be invoked from anywhere. Haskell makes a deliberate distinction: a function of type Int -> Int cannot print, cannot read a file, cannot make a network call. The way it knows is through types. Anything with IO in its return type is allowed to interact with the outside world; anything else is not.
This is not academic discipline. It is the property that makes Haskell's testing, refactoring, and reasoning work. When you see validateEmail :: Text -> Bool, you know — by the type alone — that calling it cannot launch missiles. When you see sendEmail :: Text -> IO (), you know it can.
What IO actually is
IO a is "a description of an action that, when run by the runtime, produces an a". Constructing an IO value does nothing. Running it (which only the runtime does, by evaluating main) is what causes effects.
greeting :: IO ()
greeting = putStrLn "Hello"
greeting is a value. Defining it does not print anything. Even passing it around, putting it in a list, assigning it to a variable — none of that prints. Only main (or something main ultimately reaches) actually executes the action.
This means:
ignored :: IO ()
ignored = do
let _ = putStrLn "never printed" -- not bound to <-
pure ()
That let binds a value of type IO (). The action is built but never executed. If you replaced let _ = ... with _ <- ..., the action would run. The mechanism is explicit and visible in the code.
main
Every Haskell program has exactly one entry point:
main :: IO ()
main = putStrLn "Hello, world!"
main is the action the runtime runs. Anything reachable from main runs; anything not reachable is just data lying around. The type IO () says "an action returning unit (no useful result)".
A slightly larger program:
main :: IO ()
main = do
putStr "What is your name? "
name <- getLine
putStrLn ("Hello, " ++ name ++ "!")
do is the sequencing notation from the previous topic. <- runs an IO action and binds its result to a name.
Reading and writing
The standard prelude gives you the basics:
putStr :: String -> IO () -- write without newline
putStrLn :: String -> IO () -- write with newline
print :: Show a => a -> IO () -- showing then putStrLn
getLine :: IO String -- read a line
getChar :: IO Char -- read one character
readLn :: Read a => IO a -- getLine + parse
A small interactive loop:
echoUntilQuit :: IO ()
echoUntilQuit = do
line <- getLine
if line == "quit"
then putStrLn "Goodbye."
else do
putStrLn line
echoUntilQuit
Recursion replaces a while-loop. There is no special construct.
print is just putStrLn . show:
ghci> print 42
42
ghci> print [1, 2, 3]
[1,2,3]
ghci> print "hello" -- note the quotes; print uses Show
"hello"
For working with lots of text, prefer Data.Text.IO.putStrLn and Text over String. String is [Char] — fine for examples, slow in production. Real codebases use Text (or ByteString for raw bytes).
Do-notation in IO
Three sequencing primitives, the same ones from any monad:
m >> n -- run m, then n, ignore m's result
m >>= \x -> n -- run m, bind result to x, then run n
pure x -- an IO action that does no I/O and yields x
Do-notation rephrases:
do
putStrLn "Step 1"
result <- getLine
putStrLn ("You said: " ++ result)
pure result
Desugared, that is:
putStrLn "Step 1" >>
getLine >>= \result ->
putStrLn ("You said: " ++ result) >>
pure result
Same code, different layout. IO is a monad like any other; the do-syntax is identical to what you would write for Maybe or Either.
You cannot escape IO (and that is the point)
There is no runIO :: IO a -> a. There is unsafePerformIO, but it is named that way for a reason — using it without good cause breaks every assumption other code makes. The lack of an escape hatch is not a missing feature. It is the entire design.
If runIO existed, every pure function could secretly do I/O, and the type signature would be a lie. The whole property "this function is pure unless its type says otherwise" would collapse.
What you can do — and what you do constantly — is the inverse: lift pure values into IO:
pure :: a -> IO a
Pure code freely flows into IO. IO does not flow into pure code. The arrow is one-directional.
This forces a structural pattern in real codebases:
main :: IO ()
main = do
raw <- readFile "config.json"
case parseConfig raw of
Left err -> die err -- IO
Right cfg -> runApp cfg -- mostly pure logic, IO at the edges
Read input in IO. Parse and validate in pure code. Decide what to do in pure code. Perform the side effects back in IO. This separation is what people mean when they say Haskell encourages "functional core, imperative shell" — the type system makes the boundary explicit.
What "preserves purity" means concretely
Take this:
double :: Int -> Int
double x = x + x
You can replace any call double 5 with 10 anywhere in the program without changing meaning. That is referential transparency. It works because double cannot do anything other than compute its result.
Now consider:
randomDouble :: IO Int
randomDouble = do
x <- randomRIO (1, 100)
pure (x + x)
You cannot replace randomDouble with a fixed value. But — and this is the point — the type tells you that. Any function calling randomDouble must itself return IO, and that propagates outward. The boundary of "things that can vary" is visible in the type signatures.
This is why Haskell programs are easier to test than they look. The pure parts can be tested with property-based testing (hedgehog, QuickCheck) and have no setup. The IO parts get integration tests. You can usually keep the bulk of logic pure and only the outermost layer in IO.
A more realistic shape
Here is what a small CLI looks like in production-quality Haskell:
module Main where
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import System.Exit (die)
import Text.Read (readMaybe)
data Command = Add Int Int | Sub Int Int
parseCommand :: T.Text -> Either String Command
parseCommand input =
case T.words input of
["add", a, b] -> Add <$> parseInt a <*> parseInt b
["sub", a, b] -> Sub <$> parseInt a <*> parseInt b
_ -> Left "expected: add N N | sub N N"
where
parseInt t = maybe (Left ("not a number: " <> T.unpack t)) Right
(readMaybe (T.unpack t))
run :: Command -> Int
run (Add a b) = a + b
run (Sub a b) = a - b
main :: IO ()
main = do
TIO.putStr "> "
line <- TIO.getLine
case parseCommand line of
Left err -> die err
Right cmd -> print (run cmd)
Notice the shape: parseCommand and run are pure. main is IO. The pure parts are trivially testable; the IO part is just glue.
Common pitfalls
Thinking pure x does something. It does not. It packages x into an IO action that, when run, returns x and performs no effect. Beginners often write pure (putStrLn "hi") and wonder why nothing prints. They wrapped an action in another action without ever binding it.
Forgetting <- and getting weird type errors. let name = getLine gives you name :: IO String, not name :: String. To get the actual string, use name <- getLine inside a do-block.
Reaching for unsafePerformIO. If you find yourself wanting it, you almost certainly need to thread IO through your call site. The exceptions (FFI initializers, top-level memoized resources) are rare and well-documented.
Defaulting to String for everything. It works in tutorials. In production, Text from Data.Text is faster, more compact, and preferred by every modern library. Data.Text.IO mirrors Data.IO for reading and writing.
Mixing putStrLn and Text.putStrLn accidentally. GHC's error messages here are not always obvious to beginners. Pick one (almost always Data.Text.IO) and stick with it.
Forgetting IO is lazy in its result, not its sequencing. Sequenced IO actions execute in order. But let x = expensiveIO does not execute anything; it is just a name for an action. The action runs only when bound with <- or sequenced.
Key takeaways
IO ais a description of an action that produces anawhen the runtime runs it. Constructing it has no effect.- The lack of a
runIOfunction is what keeps pure code pure. The asymmetry — pure flows into IO, never the other way — is the whole design. - Real Haskell pushes IO to the edges and keeps logic pure. "Functional core, imperative shell."
- Do-notation in IO is identical in structure to do-notation in any other monad.
<-runs and binds;purelifts. - Use
TextfromData.TextandData.Text.IOfor production;Stringis fine for examples but slow in real code. pure xis an IO action that does nothing and returnsx. It is not a no-op or a trick — it is the lift from pure values into the IO type.