How Haskell Handles Side Effects Without Breaking Its Rules
One of the most common questions about Haskell is how it handles programs that actually do things - read files, write to databases, send network requests, print output - while claiming to be a purely functional language.

One of the most common questions about Haskell is how it handles programs that actually do things - read files, write to databases, send network requests, print output - while claiming to be a purely functional language. The answer involves the IO monad, and understanding it properly changes how you think about the relationship between purity and practical programming. The IO monad is not a compromise or a workaround; it is an elegant solution to a real design problem.
The problem with side effects in pure languages
Pure functions have a simple guarantee: given the same inputs, they always produce the same output. No reading from global state, no modifying variables, no printing to the console. This guarantee is what makes pure functions easy to test, easy to reason about, and safe to evaluate in any order or multiple times.
Side effects break this guarantee. A function that reads the current time produces different values on different calls. A function that writes to a file has an observable effect beyond its return value. A function that reads from a database might observe changes made by other processes between calls. These are the behaviors that make real programs useful, and they are exactly the behaviors that pure functions cannot have.
A language that is purely functional in the strict sense - no side effects anywhere - cannot do anything observable. It can compute values but never communicate them to the outside world. This would make it useless as a general programming language. Haskell's solution is to have a way to express side-effectful computations while keeping the purity guarantees for the rest of the language.
IO as a type
The IO type in Haskell wraps computations that can interact with the outside world. A value of type IO String is not a String - it is a description of a computation that, when executed, will produce a String by performing some IO. A value of type IO () is a computation that performs IO without returning a meaningful value - the unit type () signals that the return value is unimportant.
The key insight is that an IO a value is a first-class value in Haskell. You can pass it to functions, store it in data structures, and combine it with other IO values. But you cannot "unwrap" it to get the value inside without executing the IO action. There is no function with type IO a -> a. The only way to run IO is at the top level of the program, where the Haskell runtime executes the main function.
This design keeps pure and impure code separate in a way that the type system enforces. A function that only manipulates pure values has a pure type - no IO anywhere. A function that needs to read a file has an IO type. You can tell from the type signature whether a function can interact with the outside world, and the compiler prevents pure functions from accidentally performing side effects.
Sequencing IO with do notation
IO actions in Haskell need to be sequenced - they must happen in a specific order. Reading a file and then printing its contents must happen in that order, not the other way around. The do notation provides a readable syntax for sequencing IO actions:
main = do contents <- readFile "input.txt"; putStrLn contents
The <- operator binds the result of an IO action to a name, making it available in subsequent actions. readFile "input.txt" has type IO String; the <- extracts the String from it and binds it to contents. putStrLn contents then uses that String value in the next IO action. The sequence is explicit and readable.
Do notation is syntactic sugar for the bind operator >>=. The example above desugars to readFile "input.txt" >>= \contents -> putStrLn contents. The bind operator sequences two IO actions, passing the result of the first to a function that produces the second. Understanding this desugaring helps when do notation produces surprising behavior, since the underlying mechanism is always the bind.
Mixing pure and impure code
Most Haskell programs are a combination of pure computation and IO. The recommended pattern is to keep as much logic as possible in pure functions and use IO only at the boundaries - when reading input and when writing output. This maximizes the amount of code that can be tested in isolation and minimizes the code that requires execution in an IO context.
The let keyword in do notation introduces pure bindings: let result = pureFunction input evaluates a pure expression without performing IO. This mixes pure and impure code naturally in a do block - IO actions use <-, pure bindings use let, and the combination is readable. The types distinguish the two cases clearly.
This pattern - thin IO layer, fat pure core - is the functional architecture approach applied to program design. A web server reads an HTTP request (IO), processes it with pure functions, and writes an HTTP response (IO). A command-line tool reads arguments and files (IO), computes a result with pure functions, and writes output (IO). The IO is at the edges; the logic is pure and testable.
Beyond IO: other monads for effects
The monadic approach to effects that IO demonstrates is not limited to IO itself. Haskell's typeclass system generalizes it. A computation in the State monad can read and write a state value without IO. A computation in the Reader monad can access a read-only environment. A computation in the Writer monad accumulates a log. These are not side effects in the impure sense - they are explicit, tracked effects that are part of the function's type.
The mtl (monad transformer library) stack combines multiple effects. A computation in ReaderT Config (StateT AppState IO) can read configuration, manage state, and perform IO, with all three effects reflected in the type. The monad transformer stack is the Haskell way of composing effects, and understanding it is important for reading and writing real Haskell programs beyond simple IO.
Effect systems like polysemy and effectful have emerged as alternatives to mtl for complex effect composition. They offer better type error messages, easier testing through effect interpretation, and more flexibility in how effects are combined. The choice between mtl and an effect system depends on the project's complexity and the team's familiarity with each approach, but both are grounded in the same idea: effects are tracked in types, and effect handling is explicit and composable.
Exceptions and error handling in IO
IO in Haskell can throw exceptions. The standard exception mechanism uses throw and catch from Control.Exception, but these should be used carefully. The issue is that exceptions are not reflected in the type - a function with type IO String might throw an IOException without the type indicating this. This breaks the type-as-documentation principle.
The preferred approach for recoverable errors is to use explicit error types. A function that might fail returns IO (Either Error Result) rather than IO Result. The Either type makes the possibility of failure visible in the type, and callers must handle the error case explicitly by pattern matching. This is more verbose than exceptions but more honest about what the function can do.
Exceptions are still appropriate for truly exceptional conditions: out-of-memory errors, impossible program states, unrecoverable failures. The distinction is between business logic errors (expected failures that the caller should handle) and runtime failures (unexpected conditions that typically terminate the program). Exceptions are for the latter; Either or Maybe is for the former.
Purity as a practical tool
The reason Haskell's purity matters is not philosophical but practical. Pure code is easier to test because it does not require setting up an IO environment. Pure code is easier to reason about because its behavior is determined entirely by its inputs. Pure code is easier to refactor because you can move pure functions around without worrying about execution order or shared state.
The IO monad is the mechanism that allows Haskell to be practical while preserving these benefits for as much of the codebase as possible. By confining impurity to explicitly-typed IO values, Haskell maximizes the amount of code that has the benefits of purity. The result is a language where the division between safe-to-test-in-isolation logic and side-effectful operations is structural and enforced, not a convention that developers try to follow and sometimes forget.
More from the blog
Programming
Lazy Evaluation in Haskell and Why It Actually Matters
Lazy evaluation is one of Haskell's most distinctive features and one of its most misunderstood.
Programming
How Purely Functional Programming Changes the Way You Think
Learning purely functional programming does not just teach you a new language - it teaches you a different way of thinking...
Systems
Why Systems Programmers Are Choosing Haskell for New Projects
Systems programming has long been dominated by C, C++, and more recently Rust.