7 min read
On this page

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:

  1. 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.
  2. 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.
  3. 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:

  1. Never catch SomeException directly. It catches async exceptions, which you almost never want to handle.
  2. Always use bracket (or withFile, withMVar, etc.) for resource handling. Hand-rolled try/finally chains miss async safety.

This is why safe-exceptions exists.

safe-exceptions

The safe-exceptions library wraps Control.Exception with safer defaults:

  • Its catch, handle, and try only catch synchronous exceptions. Async exceptions pass through.
  • Its bracket is the same as Control.Exception.bracket (which was already async-safe).
  • It exposes catchAny and tryAny for 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. bracket wraps any cleanup pattern. Even if your business logic uses Either, bracket lives 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-client throws on connection failures. persistent throws on connection-pool exhaustion. The Aeson decode' family does not throw, but decode callers have other expectations. You usually catch these at a boundary and convert to Either-shaped errors.
  • The IO subsystem. Anything in System.IO throws IOException for 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 Maybe or Either.

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 throwIO in IO. Avoid throw outside of pure code where you genuinely want a thunk-thrown exception.
  • bracket is the universal pattern for resource-safe code. withFile, withMVar, withConnection are bracket-shaped wrappers you should prefer when they exist.
  • Async exceptions can arrive at any time. Catching SomeException swallows them and breaks cancellation.
  • Use safe-exceptions (or unliftio) instead of Control.Exception directly. Its catch/try only fire on synchronous exceptions, which is almost always what you want.
  • Convert library exceptions to Either AppError at the boundary so the rest of your code can use typed errors with exhaustive handling.
  • error is for "this can never happen". User-facing failures belong in types.