Haskell Monad Transformers Step by Step With a Real App
You have a function that needs configuration, can fail with a typed error, and has to print to the console, and suddenly every signature is a tower of nested case expressions.

You have a function that needs configuration, can fail with a typed error, and has to print to the console, and suddenly every signature is a tower of nested case expressions. Haskell monad transformers exist to flatten that tower, but most tutorials stop at a toy counter in StateT and never show what a real application stack looks like.
This guide builds one small program from plain IO up to a three layer stack, explains what each layer actually costs you, and ends with the pattern most production Haskell code settles on. If you are still shaky on the Monad class itself, read our walkthrough of type classes with real examples first, because transformers are mostly instance resolution in disguise.
The problem Haskell monad transformers solve
Picture a tiny tool that reads a config, loads and validates a user, and prints a greeting. In plain IO it looks fine for twenty lines. Then you add error handling with Either, and every step becomes a case expression that unwraps a Left or a Right before moving on. Add the config record and you are passing it through six functions that only forward it to a seventh. The logic drowns in plumbing, and plumbing is where bugs hide.
A monad transformer takes an existing monad and adds one capability to it. ReaderT adds read only access to an environment. ExceptT adds short circuiting failure with a typed error. StateT adds a value you can read and replace. Stack them and you get one monad that has all three abilities, with the plumbing handled by the bind operator instead of by you.
Building the stack one layer at a time
Start with the innermost layer, which is almost always IO in an application.
Step 1: add ReaderT for configuration
Define a Config record with fields such as the data directory and a verbosity flag. Your monad becomes ReaderT Config IO. Inside it, ask returns the whole record and asks dataDir returns one field.
Step 2: add ExceptT for typed errors
Define an AppError sum type with constructors like UserNotFound and InvalidAge. The stack becomes ExceptT AppError (ReaderT Config IO). Now throwError (UserNotFound name) stops the computation, and the caller decides what to do with it at the edge of the program by running the stack with runExceptT and pattern matching once.
Order matters here. With ExceptT on the outside of ReaderT, an error discards nothing important because Reader has no state to lose. Put ExceptT around StateT, though, and a thrown error also throws away the state changes made before it. Put StateT around ExceptT and the state survives. Neither is wrong; you have to pick on purpose.
Step 3: lift IO actions through the layers
A plain IO action like putStrLn does not type check inside the stack because its type says IO, not your stack. liftIO from Control.Monad.IO.Class fixes this in one call regardless of how many layers sit above IO, whereas chains of lift break whenever you add a layer.
Step 4: give the stack a name
Write a newtype App wrapping the full stack and derive Functor, Applicative, Monad, MonadIO, MonadReader Config and MonadError AppError with GeneralizedNewtypeDeriving. Then one runApp function unwraps it: it takes a Config and an App action and returns IO (Either AppError a). Every function in the program now has a signature like loadUser :: Text -> App User, which reads as plain intent.
The newtype matters more than it looks. It keeps error messages short, since GHC prints App instead of three nested type constructors, and it lets you change the stack later without editing every signature. If a stack error does slip through, our guide to reading GHC error messages without panicking shows how to find the line that matters.
mtl style constraints versus a concrete stack
The mtl library adds classes such as MonadReader, MonadState and MonadError. Instead of naming the concrete stack, a function can say it needs (MonadReader Config m, MonadError AppError m) => m User. Any monad with those abilities will do.
This helps library code and tests, at the cost of longer type errors and possible slowdowns if GHC does not specialise it. For application code, a concrete App newtype with mtl instances derived on it gets you most of the benefit with fewer surprises.
| Layer | Adds | Key operations | Watch out for |
|---|---|---|---|
| ReaderT r | Read only environment | ask, asks, local | Almost nothing; it is the safest layer |
| ExceptT e | Typed short circuit failure | throwError, catchError | Does not catch IO exceptions |
| StateT s | Mutable looking value | get, put, modify | State lost on exceptions; lazy version leaks |
| WriterT w | Accumulated log | tell | Leaks space in long runs; avoid in services |
Why the tall stack is often the wrong answer
Most tutorials present transformers as something to stack generously. In long running services, that advice ages badly, and the reason is exceptions.
ExceptT only handles errors you throw with throwError. A file that cannot be opened, a network timeout or a killed thread raises an IO exception that bypasses ExceptT entirely. So your code now has two error channels, and every handler has to think about both. StateT has a related flaw: when an IO exception escapes, the state accumulated inside StateT is gone, and functions like bracket and finally from base cannot restore it because they only know about IO.
WriterT is worse. The standard lazy version builds up thunks as you call tell, and the strict version in older transformers releases is not strict enough to stop it. A service that logs through WriterT for a few hours can show the same symptoms we walk through in finding and fixing a Haskell space leak in production.
The common fix is the ReaderT design pattern: keep the stack at exactly ReaderT Env IO. Mutable state lives in an IORef or TVar stored inside Env. Errors are ordinary exceptions thrown with throwIO, with your own exception types, and caught where you can do something useful. Logging is a function stored in Env. You lose some purity, but you gain one error channel, predictable state under exceptions, and compatibility with libraries like unliftio that need to run your monad back in IO.
ExceptT is still fine inside pure validation code with no IO. The pushback is against stacking it around IO.
A practical checklist before you add a layer
- Ask whether the capability is truly needed across many functions, or only in two. If two, pass an argument.
- If you want state in IO code, prefer an IORef in a Reader environment over StateT.
- If you want typed errors, decide whether IO exceptions also need handling. If yes, use exception types and throwIO.
- Wrap whatever you choose in a newtype from day one.
- Use the strict StateT from Control.Monad.State.Strict in pure code and avoid WriterT in anything long running.
Frequently asked questions
What is the difference between transformers and mtl in Haskell?
The transformers package defines the concrete types such as ReaderT, StateT and ExceptT, plus lift. The mtl package builds on it and adds classes like MonadReader and MonadState so you can call ask or get without manual lifting. Most projects depend on both.
Does the order of monad transformers matter?
Yes. ExceptT outside StateT discards state on error, while StateT outside ExceptT keeps it. ReaderT is usually order insensitive because it has no state to lose. Pick the order based on what you want to happen when something fails.
Are monad transformers slow in Haskell?
A concrete stack of two or three layers compiles to fast code with optimisations on, and the overhead rarely shows in profiles next to IO. Polymorphic mtl code can be slower if GHC cannot specialise it, which a SPECIALISE pragma or a concrete App type usually solves.
More in Tutorials
Tutorials
Parsing JSON in Haskell With Aeson Step by Step
The first time I parsed a real API response in Haskell, the payload had 41 fields, three of them optional, one of them a...
Tutorials
How to Read GHC Error Messages Without Panicking
The first time most people compile a twenty line Haskell program, GHC answers with forty lines of complaint.
Tutorials
Fixing Cabal's "Could Not Resolve Dependencies" Error
You clone a Haskell project that built fine for its author last spring, run cabal build, and get a wall of text that starts...