100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Programming

The IO Monad

Understand how Haskell models side effects with the IO monad -- IO values as pure descriptions of effects, do-notation sequencing, why you can't escape IO into pure code, and how to keep most logic pure.

Functional AbstractionsIntermediate9 min readJul 10, 2026
Analogies

IO as a Monad: Describing Effects as Values

In Haskell, a value of type IO a is not the result of performing a side effect -- it is a pure, first-class description of an effectful action that, when executed by the runtime, will produce a value of type a. Writing IO a values (composing them, passing them around, storing them in lists) is itself pure and referentially transparent; only the runtime's actual execution of main's resulting IO action touches the real world, which is how Haskell keeps effects explicit in the type system without giving up purity.

🏏

Cricket analogy: A written match plan describing 'toss, then bowl first, then rotate bowlers at over 10' is a pure description of intended actions, not the actions themselves -- exactly like an IO a value describes an effect without performing it until the umpire actually starts play.

Sequencing IO Actions with do-Notation

Because IO is a Monad, do-notation sequences IO actions the same way it sequences any monad: name <- getLine runs the IO String action getLine, binds its result to name, and the rest of the do-block becomes the function fed into >>=. Each line executes in order, side effects and all, which is exactly the ordering a Monad's >>= enforces -- you could not, for example, print the greeting before reading the name, because the greeting's IO action is built from the bound name value that doesn't exist yet.

🏏

Cricket analogy: do { tossResult <- callToss; decision <- if tossResult == Won then chooseBat else return Bowl; startInnings decision } sequences cricket actions in strict order -- you can't announce the batting decision before the toss result is actually known, just like IO's do-notation.

haskell
main :: IO ()
main = do
  putStrLn "What is your name?"
  name <- getLine
  let greeting = "Hello, " ++ name ++ "!"
  putStrLn greeting
  putStrLn "Enter your favorite number:"
  numStr <- getLine
  let num = read numStr :: Int
  putStrLn ("Double that is " ++ show (num * 2))

main :: IO () is the single required entry point of every Haskell program -- GHC compiles your program down to one big IO action bound to main, and the runtime system executes exactly that action to start the program; everything else in the file is pure until it's wired into that action.

IO and Purity: main, unsafePerformIO, and Why You Can't Escape

Unlike Maybe a, where you can pattern-match to extract the a out of a Just a, there is no general, safe function IO a -> a -- you cannot 'unwrap' an IO action into a plain value from pure code, because doing so would let a side effect leak into a context that's supposed to be pure and referentially transparent. The only sanctioned way to get a value out of an IO a is to bind it inside another IO computation with <-, keeping the effect contained within IO all the way up to main.

🏏

Cricket analogy: There's no way to peek at tomorrow's toss result from today's team-selection meeting -- the actual coin flip only happens live on match day, just as an IO a value can only be 'observed' by running it, never extracted into pure pre-match analysis.

Combining IO with Pure Code

The idiomatic Haskell pattern is to push IO to the thin outer edges of a program and keep the bulk of the logic as pure functions: read raw input with IO, hand it to a pure function that computes a result, then print that result with IO. fmap, <*>, and >>= let you weave pure functions into an IO pipeline without writing everything as an explicit do-block -- fmap (*2) parseNumberLine applies a pure transformation to a value that's still 'inside' IO, keeping the doubling logic itself pure and testable.

🏏

Cricket analogy: A team analyst who keeps the actual live scoring (IO) confined to the scorer's booth while running all statistical projections (pure functions) offline on historical data mirrors pushing IO to the edges and keeping core logic pure and testable.

unsafePerformIO :: IO a -> a does technically exist in System.IO.Unsafe and can extract a value from an IO action, but it breaks Haskell's purity guarantees -- the compiler may reorder, duplicate, or eliminate calls to it under laziness, producing different results than you expect. It's reserved for narrow cases like FFI bindings to genuinely pure C functions or debugging with Debug.Trace, never for everyday application logic.

  • An IO a value is a pure description of an effect; only runtime execution of main's IO action touches the real world.
  • do-notation sequences IO actions via >>=, exactly like any other Monad instance.
  • main :: IO () is the single entry point that GHC's runtime actually executes.
  • There is no safe IO a -> a function; values can only be extracted inside another IO computation via <-.
  • Idiomatic Haskell pushes IO to the edges and keeps core logic as pure, testable functions.
  • fmap, <*>, and >>= let pure functions be woven into IO pipelines without full do-blocks.
  • unsafePerformIO breaks referential transparency and should be avoided outside narrow FFI/debugging cases.

Practice what you learned

Was this page helpful?

Topics covered

#Programming#HaskellStudyNotes#TheIOMonad#Monad#Describing#Effects#Values#StudyNotes#SkillVeris#ExamPrep