Exceptions
Haskell has exceptions. This surprises people who have heard "Haskell uses types for errors" and assumed that meant exceptions do not exist or are forbidden. They exist. They are sometimes the right tool. The trick is knowing when.
The short version: exceptions are for situations where (a) the error is genuinely exceptional — not part of normal program flow — or (b) the runtime forces them on you, like async cancellation or stack overflow. Use Either or a custom error type for everything else.
This page covers how exceptions actually work in GHC Haskell, how to throw and catch them, why bracket matters more than you think, and why almost everyone in production uses the safe-exceptions library instead of Control.Exception directly.
How exceptions arrive
Haskell exceptions show up in three flavours:
- Synchronous exceptions thrown from your own code or from a library. A file does not exist, JSON parsing failed, a divide-by-zero in pure code. These behave like exceptions in any language.
- Asynchronous exceptions thrown by another thread. Most commonly the runtime cancelling a thread (timeouts,
cancel, user interrupt). These can arrive at any moment, which is what makes them tricky. - Imprecise exceptions from pure code.
error "boom",undefined, partial pattern matches, division by zero. These lurk inside thunks until the thunk is evaluated.
The Control.Exception module is the underlying API. The safe-exceptions library (Snoyman, Galois, and others — used widely in production) is the same API with sharper semantics around async exceptions. We will start with the basics and then explain why everyone reaches for safe-exceptions.
Throwing
import Control.Exception
data ConfigError = MissingKey String deriving Show
instance Exception ConfigError
Any type with an Exception instance can be thrown. Show is required (so the runtime can print uncaught exceptions); Typeable is automatically derived in modern GHC.
Two throwing functions:
throw :: Exception e => e -> a -- pure code
throwIO :: Exception e => e -> IO a -- IO code
throw is dangerous. It throws from pure code, which means the exception is hidden inside a thunk and only fires when the thunk is forced. The order of effects becomes unpredictable. Use throwIO from IO:
loadConfig :: FilePath -> IO Config
loadConfig path = do
exists <- doesFileExist path
if not exists
then throwIO (MissingKey path)
else parseConfig path
throwIO happens at the point of the call, ordered with respect to other IO. throw happens whenever the resulting thunk is evaluated, which might be much later. This distinction trips up beginners regularly.
Catching
catch :: Exception e => IO a -> (e -> IO a) -> IO a
handle :: Exception e => (e -> IO a) -> IO a -> IO a -- arguments flipped
try :: Exception e => IO a -> IO (Either e a)
Use:
loadOrDefault :: FilePath -> IO Config
loadOrDefault path =
loadConfig path `catch` \(MissingKey _) -> pure defaultConfig
The type annotation on the handler matters: the catch only fires for that specific exception type. A MissingKey handler does not catch a network error.
If you want to catch any exception, use SomeException:
catchAny :: IO a -> IO a -> IO a
catchAny act fallback = act `catch` \(_ :: SomeException) -> fallback
This is almost always wrong. Catching SomeException swallows async exceptions like thread cancellation, which prevents your program from being killable. This is the mistake safe-exceptions is designed to prevent.
try returns Either:
result <- try (loadConfig path) :: IO (Either IOException Config)
case result of
Left err -> ...
Right cfg -> ...
try is often the most ergonomic of the three when you want to convert an exception into an Either-shaped error.
bracket: the most important function in this module
bracket
:: IO a -- acquire
-> (a -> IO b) -- release
-> (a -> IO c) -- use
-> IO c
bracket runs the acquire action, then the use action, and guarantees the release action runs no matter what — exception, async cancel, normal completion. This is how you write resource-safe code.
import Control.Exception
import qualified Data.ByteString as BS
import System.IO
readBinaryFile :: FilePath -> IO BS.ByteString
readBinaryFile path =
bracket (openFile path ReadMode) hClose BS.hGetContents
If BS.hGetContents throws, hClose still runs. If the thread is killed by cancel while in the middle of reading, hClose runs. There is no try/finally/catch chain that gets this right by hand for async exceptions; bracket masks them at the right moments.
Variants:
bracket_ :: IO a -> IO b -> IO c -> IO c -- no value passed to release
finally :: IO a -> IO b -> IO a -- run cleanup, no acquire
onException :: IO a -> IO b -> IO a -- cleanup only on exception
finally is bracket_ for "run this no matter what" without an explicit acquire step:
runCriticalSection action `finally` releaseLock
error, undefined, and friends — partial functions
error :: String -> a
errorWithoutStackTrace :: String -> a
undefined :: a
These all throw ErrorCall. They are for programmer errors — situations the code should never reach. "I called head []" is a programmer error: the code is wrong. "The user typed an invalid email" is not — that is data, and it deserves a typed error.
Library code that uses error for invalid inputs is annoying because callers cannot recover meaningfully. Reach for Either or Maybe instead, and reserve error for assertions about your own code's invariants.
Async exceptions: where it gets hard
GHC's runtime uses exceptions for thread cancellation. When you call cancel on an async, an AsyncException is delivered to the target thread at any point. It can arrive in the middle of bracket's use phase. It can arrive between two pure operations.
bracket handles this correctly because it uses mask internally to defer async exceptions during acquire and release. Naively wrapping IO in try does not — async exceptions get caught and silently swallowed, leaving your program in a bad state and impossible to kill.
Two rules:
- Never catch
SomeExceptiondirectly. It catches async exceptions, which you almost never want to handle. - Always use
bracket(orwithFile,withMVar, etc.) for resource handling. Hand-rolledtry/finallychains miss async safety.
This is why safe-exceptions exists.
safe-exceptions
The safe-exceptions library wraps Control.Exception with safer defaults:
- Its
catch,handle, andtryonly catch synchronous exceptions. Async exceptions pass through. - Its
bracketis the same asControl.Exception.bracket(which was already async-safe). - It exposes
catchAnyandtryAnyfor when you really do want to catch all synchronous exceptions, with explicit naming.
Using it:
import qualified Control.Exception.Safe as Safe
loadOrDefault :: FilePath -> IO Config
loadOrDefault path =
Safe.catchAny (loadConfig path) (\_ -> pure defaultConfig)
catchAny here will not swallow async exceptions, so the thread is still cancellable. Most production Haskell shops standardize on safe-exceptions because the default Control.Exception is genuinely a footgun for async-aware code.
unliftio (also Snoyman, also commonly used) re-exports the same safe variants and adds support for monads other than IO.
When are exceptions the right tool?
A short list:
- Resource cleanup.
bracketwraps any cleanup pattern. Even if your business logic usesEither,bracketlives at the IO boundary. - Programmer errors and invariant violations. Genuinely-impossible cases.
error "this should be unreachable". - Async cancellation. You do not throw these yourself, but you handle them — usually by letting them propagate while making sure resources clean up via
bracket. - Library errors that arrive as exceptions.
http-clientthrows on connection failures.persistentthrows on connection-pool exhaustion. The Aesondecode'family does not throw, butdecodecallers have other expectations. You usually catch these at a boundary and convert toEither-shaped errors. - The IO subsystem. Anything in
System.IOthrowsIOExceptionfor missing files, permission denied, etc. You convert these at the edge.
When are exceptions the wrong tool?
- Validation errors, business-rule violations, anything you would describe as "the user did the wrong thing". Use
Either. - Anything where the caller needs to enumerate possible failures. Exceptions are open-ended; the type system cannot tell you what might be thrown.
- Pure code that wants to return failure. Use
MaybeorEither.
A realistic pattern
The boundary between exception-land and Either-land is usually a small layer that catches and converts:
import qualified Control.Exception.Safe as Safe
import Network.HTTP.Client
data AppError
= NetworkError String
| DecodeError String
| NotFound
deriving Show
fetchJson :: Manager -> String -> IO (Either AppError BL.ByteString)
fetchJson mgr url = Safe.try (httpLbs req mgr) >>= \case
Left (e :: HttpException) -> pure (Left (NetworkError (show e)))
Right resp ->
case responseStatus resp of
s | statusCode s == 404 -> pure (Left NotFound)
| statusIsSuccessful s -> pure (Right (responseBody resp))
| otherwise -> pure (Left (NetworkError (show s)))
where
req = parseRequest_ url
The HTTP library throws on network errors; this wrapper catches and translates. From here on, the rest of the program lives in Either AppError-land where errors are typed and exhaustive.
Common pitfalls
Catching SomeException. The single most common bug. It eats async exceptions and makes threads unkillable. Use safe-exceptions's catchAny instead, which only catches synchronous ones.
Hand-rolling cleanup with try and finally. Edge cases around async exceptions are subtle. Use bracket. If bracket is not enough, use bracketOnError or mask.
throw instead of throwIO in IO. Means the exception is hidden in a thunk and fires unpredictably. Use throwIO in IO.
error for input validation. error is for "this code is wrong" not "the data is wrong". The user should not crash your server because they typed a bad email.
Catching too broad, then losing the type. try act :: IO (Either SomeException a) gives you back a SomeException that you usually need to cast to handle. Catch the specific exception type when you can.
Forgetting that pure code can throw. head [] throws Prelude.head: empty list. 1 div 0 throws divide by zero. These are imprecise exceptions and arrive when the thunk is forced — possibly far from where the bug is.
Mixing Control.Exception and Control.Exception.Safe in the same module. Pick one. The semantics of catch differ between them, and surprises follow.
Key takeaways
- Haskell exceptions exist and are appropriate for resource cleanup, programmer errors, async cancellation, and conversion at IO boundaries.
- Throw with
throwIOinIO. Avoidthrowoutside of pure code where you genuinely want a thunk-thrown exception. bracketis the universal pattern for resource-safe code.withFile,withMVar,withConnectionare bracket-shaped wrappers you should prefer when they exist.- Async exceptions can arrive at any time. Catching
SomeExceptionswallows them and breaks cancellation. - Use
safe-exceptions(orunliftio) instead ofControl.Exceptiondirectly. Itscatch/tryonly fire on synchronous exceptions, which is almost always what you want. - Convert library exceptions to
Either AppErrorat the boundary so the rest of your code can use typed errors with exhaustive handling. erroris for "this can never happen". User-facing failures belong in types.