Cached at:
05/11/26, 01:02 PM
# Exception Annotations: Lay of the Land
Source: [https://well-typed.com/blog/2026/05/lay-annotation-land/](https://well-typed.com/blog/2026/05/lay-annotation-land/)
Exception annotations were introduced in GHC 9\.10, and can be an invaluable tool for debugging thorny problems\. The initial implementation had some important limitations that made them less useful in practice than one might hope, but fortunately the situation has since been much improved\. In this blog post we will give a detailed overview of the status quo as of GHC 9\.12/9\.14, identify some gotchas you should be aware and provide advise on how to deal with them, and briefly look ahead to what will change in GHC 10\.0\. We will also dedicate a section to discussing the problems in GHC 9\.10, for those who cannot yet upgrade\.
Although we will recap all necessary definitions, this blog is*not*meant to be an introduction to exception annotations; if you have never used them before, you might first want to watch[The Haskell Unfolder Episode 29: exceptions, annotations and backtraces](https://www.youtube.com/watch?v=SwOkh9N41Y8&list=PLD8gywOEY4HaG5VSrKVnHxCptlJv2GAn7&index=30)\.
## Backtraces
Before we look at the general framework for exception annotations, let’s first briefly recap the concept of*backtraces*, which is GHC’s answer to stack traces in other languages\. The situation is more complicated in Haskell due laziness, and there are actually four different kinds of backtraces:
- based on`HasCallStack`annotations
- based on cost\-centres \(which will require compiling your program with profiling enabled\)
- based on[IPE info](https://well-typed.com/blog/2021/01/first-look-at-hi-profiling-mode/)
- based on[DWARF info](https://well-typed.com/blog/2020/04/dwarf-1/)
In this blog post we will use the first two only, but for the purposes of our main discussion here the choice actually does not matter much; see GHC proposal[Decorate exceptions with backtrace information](https://github.com/ghc-proposals/ghc-proposals/blob/master/proposals/0330-exception-backtraces.rst)for details\. If you’re interested in IPE backtraces specifically, you might also be interested in our blog post[Better Haskell stack traces via user annotations](https://well-typed.com/blog/2025/09/better-haskell-stack-traces/), which discusses some recent extensions we implemented to improve these\.
### `HasCallStack`backtraces
Consider this simple Haskell program, where`main`calls`top`calls`middle`calls`bottom`:
```
bottom :: HasCallStack => IO ()
bottom = do
bt <- collectBacktraces
putStrLn $ displayBacktraces bt
middle :: HasCallStack => IO ()
middle = bottom
top :: HasCallStack => IO ()
top = middle
main :: IO ()
main = top
```
A`HasCallStack`is essentially an additional function argument which is automatically populated by GHC at call sites with information about where the function was called\. When we run this program, we see something like this:
```
HasCallStack backtrace:
collectBacktraces, called at exe/DemoCallStack.hs:13:11 in (..)
bottom, called at exe/DemoCallStack.hs:18:10 in (..)
middle, called at exe/DemoCallStack.hs:22:7 in (..)
top, called at exe/DemoCallStack.hs:25:8 in (..)
```
The only thing worth noting here is that the moment a`HasCallStack`chain is broken, the backtrace is cut off there\. For example, if`middle`does not have a`HasCallStack`constraint, we can no longer see where`middle`was called from:
```
HasCallStack backtrace:
collectBacktraces, called at exe/DemoCallStack.hs:19:11 in (..)
bottom, called at exe/DemoCallStack.hs:24:10 in (..)
```
The fact that`top`still has a`HasCallStack`constraint does not matter: the callstack is cut at the first missing link\.
### Cost centre backtraces
Cost centres are how GHC implements profiling: very roughly, the cost of a computation is attributed to its enclosing cost centre \(see chapter[Profiling](https://downloads.haskell.org/ghc/latest/docs/users_guide/profiling.html)of the GHC manual\)\. Like`HasCallStack`, this relies on source code annotations:
```
{-# SCC bottom #-}
bottom :: HasCallStack => IO ()
bottom = do
bt <- collectBacktraces
putStrLn $ displayBacktraces bt
{-# SCC middle #-}
middle :: IO ()
middle = bottom
{-# SCC top #-}
top :: HasCallStack => IO ()
top = middle
{-# SCC main #-}
main :: IO ()
main = do
setBacktraceMechanismState CostCentreBacktrace True
top
```
Unlike`HasCallStack`, however, GHC offers ways for inserting such annotations automatically, which can often make cost centre based callstacks more practical than`HasCallStack`\. The most common flag to do this is`\-fprof\-auto`or \(in recent GHC\)`\-fprof\-late`\(see[Late Cost Centre Profiling](https://well-typed.com/blog/2023/03/prof-late/)\)\. This inserts cost centres around all top\-level functions, as we did manually above\.
Cost centre backtraces must be explicitly enabled by calling[`setBacktraceMechanismState`](https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception-Backtrace.html#v:setBacktraceMechanismState%5D), and you need to compile your code with profiling enabled; the[`cabal`option`\-\-enable\-profiling`](https://cabal.readthedocs.io/en/stable/how-to-enable-profiling.html)both enables profiling as well as automatic cost centre insertion\. The backtrace for this example might look something like
```
Cost-centre stack backtrace:
DemoCallStack.main (exe/DemoCallStack.hs:(32,1)-(34,7))
DemoCallStack.top (exe/DemoCallStack.hs:28:1-12)
DemoCallStack.middle (exe/DemoCallStack.hs:24:1-15)
DemoCallStack.bottom (exe/DemoCallStack.hs:(18,1)-(20,35))
```
Be aware however that optimizations can delete cost centres, especially in simple examples like this \([\#27225](https://gitlab.haskell.org/ghc/ghc/-/issues/27225)\)\.
### Cost centres vs exception handling
Consider the following example: as before,`main`calls`top`calls`middle`calls`bottom`, which prints a backtrace; however`bottom`then throws an excepton\. Meanwhile,`main`installs an exception handler called`handlerTop`, which in turn calls`handlerMiddle`calls`handlerBottom`, which prints its own backtrace:
```
bottom :: HasCallStack => IO ()
bottom = do
bt <- collectBacktraces
putStrLn $ displayBacktraces bt
throwIO $ userError "Uhoh"
middle :: HasCallStack => IO ()
middle = bottom
top :: HasCallStack => IO ()
top = middle
handlerBottom :: HasCallStack => SomeException -> IO ()
handlerBottom _e = do
bt <- collectBacktraces
putStrLn $ displayBacktraces bt
handlerMiddle :: HasCallStack => SomeException -> IO ()
handlerMiddle e = handlerBottom e
handlerTop :: HasCallStack => SomeException -> IO ()
handlerTop e = handlerMiddle e
main :: IO ()
main = do
setBacktraceMechanismState CostCentreBacktrace True
top `catch` handlerTop
```
The`HasCallStack`backtrace printed by`bottom`is
```
HasCallStack backtrace:
collectBacktraces, called at exe/DemoCCS.hs:24:11 in (..)
bottom, called at exe/DemoCCS.hs:29:10 in (..)
middle, called at exe/DemoCCS.hs:32:7 in (..)
top, called at exe/DemoCCS.hs:41:5 in (..)
```
as before; the`HasCallStack`printed by`handlerBottom`is very similar:
```
HasCallStack backtrace:
collectBacktraces, called at exe/DemoCCS.hs:13:11 in (..)
handlerBottom, called at exe/DemoCCS.hs:17:19 in (..)
handlerMiddle, called at exe/DemoCCS.hs:20:16 in (..)
handlerTop, called at exe/DemoCCS.hs:41:18 in (..)
```
For the cost\-centre based backtrace, the one shown in`bottom`is as before:
```
Cost-centre stack backtrace:
DemoCCS.main (exe/DemoCCS.hs:(39,1)-(41,27))
DemoCCS.top (exe/DemoCCS.hs:32:1-12)
DemoCCS.middle (exe/DemoCCS.hs:29:1-15)
DemoCCS.bottom (exe/DemoCCS.hs:(23,1)-(26,30))
```
but the one shown in`handlerBottom`is more surprising:
```
Cost-centre stack backtrace:
DemoCCS.main (exe/DemoCCS.hs:(39,1)-(41,27))
DemoCCS.top (exe/DemoCCS.hs:32:1-12)
DemoCCS.middle (exe/DemoCCS.hs:29:1-15)
DemoCCS.bottom (exe/DemoCCS.hs:(23,1)-(26,30))
DemoCCS.handlerTop (exe/DemoCCS.hs:20:1-30)
DemoCCS.handlerMiddle (exe/DemoCCS.hs:17:1-33)
DemoCCS.handlerBottom (exe/DemoCCS.hs:(12,1)-(14,35))
```
Whether or not this is expected/correct behaviour is arguable, but the rule is this: the cost centre stack is not restored*until we leave the scope of`catch`*\. Put another way: the cost centre stack reflects the fact that`bottom`“calls”`handlerTop`, however indirectly\. This applies transitively: if`handlerTop`would throw an exception, which would then be caught by some other exception handler, then*its*backtrace would reflect that`top`“called”`handlerTop`“called” that other exception handler\. This kind of situation can arise quite naturally, for example when using handlers that deallocate some resources and then*rethrow*the exception\.
## Basic definitions
Before we look at the subtleties that arise from actually catching and throwing \(or rethrowing\) exceptions, we’ll first get the basic definitions out of the way\. These have not changed much between recent GHC versions and are hopefully uncontroversial\.
### Exception annotations
Exceptions annotations can basically be anything at all; the only requirement is that that we can display them:
```
class Typeable a => ExceptionAnnotation a where
displayExceptionAnnotation :: a -> String
```
An important instance of this class is[`Backtraces`](https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception-Backtrace.html#t:Backtraces), which wraps a set of different kinds of backtraces:
```
instance ExceptionAnnotation Backtraces where
displayExceptionAnnotation = Base.displayBacktraces
```
### Exception context
An exception context is essentially just a list of exception annotations\. However, since those annotations may be of different types, we need to wrap them in an existential:
```
data ExceptionContext = ExceptionContext [SomeExceptionAnnotation]
data SomeExceptionAnnotation = forall a. ExceptionAnnotation a => SomeExceptionAnnotation a
```
There are functions for manipulating the exception context\. The most important are[`emptyExceptionContext`](https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception-Context.html#v:emptyExceptionContext)and[`addExceptionAnnotation`](https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception.html#v:addExceptionContext), for creating an empty context and inserting an annotation into an existing context respectively\.
```
emptyExceptionContext :: ExceptionContext
addExceptionAnnotation :: ExceptionAnnotation a => a -> ExceptionContext -> ExceptionContext
```
### Pivotal change:`SomeException`
The pivotal change in all of this is in the definition of`SomeException`which, starting in GHC 9\.10, now has an associated list of annotations:
```
data SomeException = forall e. (Exception e, HasExceptionContext) => SomeException e
type HasExceptionContext = (?exceptionContext :: ExceptionContext)
```
The use of an[implicit parameter](https://downloads.haskell.org/ghc/latest/docs/users_guide/exts/implicit_parameters.html#extension-ImplicitParams)means that pattern matching on`SomeException`remains possible in the same way as before \(though the annotations would be silently dropped\)\.
There are various functions for extracting and manipulating the exception context associated with an exception, such as[`someExceptionContext`](https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception.html#v:someExceptionContext)and[`addExceptionContext`](https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception.html#v:addExceptionContext):
```
someExceptionContext :: SomeException -> ExceptionContext
addExceptionContext :: ExceptionAnnotation a => a -> SomeException -> SomeException
```
However, probably the most important function for extending exception contexts is[`annotateIO`](https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception.html#v:annotateIO), which installs an exception handler that extends any exception that is thrown with the specified annotation:
```
annotateIO :: forall e a. ExceptionAnnotation e => e -> IO a -> IO a
annotateIO ann (IO io) = IO (PrimOp.catch# io handler)
where
handler se = PrimOp.raiseIO# (addExceptionContext ann se)
```
It is important to emphasize that this is implemented with[primops](https://hackage-content.haskell.org/package/ghc-prim-0.13.0/docs/GHC-PrimopWrappers.html), not with the regular`catch`and`throwIO`functions, which do considerably more than merely catching and throwing, as we shall see\.
## `Exception`type class
The`Exception`type class is a central abstraction in Haskell’s exception ecosystem\. As part of the exception annotation work, it has received one minor extension, and it was changed in two not\-so\-minor\-but\-rather\-subtle ways\. Let’s first get the part out of the way which has*not*changed: exceptions are no good if we cannot see them:
```
class (Typeable e, Show e) => Exception e where
displayException :: e -> String
displayException = show
-- (..)
```
### `backtraceDesired`
The minor extension is a new function called[`backtraceDesired`](https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception.html#v:backtraceDesired), which indicates if a backtrace should be attached to exceptions of this type; we will see how this function is used when we discuss the[implementation of`throwIO`](https://well-typed.com/blog/2026/05/lay-annotation-land/#implementation)\.
```
class (Typeable e, Show e) => Exception e where
-- (..)
backtraceDesired :: e -> Bool
backtraceDesired _ = True
```
The argument to`backtraceDesired`is already fully constructed exception; the question is whether a backtrace should be*added*to that exception\. In most cases the argument can simply be ignored, but it doesn’t*have*to be\. For all but a handful of specialized cases the default implementation \(indicating that yes, we want a backtrace\) will be fine\.
### `fromException`
The not\-so\-minor\-but\-rather\-subtle changes are in`fromException`and`toException`, which remove and add the`SomeException`wrapper around exceptions respectively\. Let’s first look at`fromException`:
```
class (Typeable e, Show e) => Exception e where
-- (..)
fromException :: SomeException -> Maybe e
fromException (SomeException e) = cast e
```
This may*look*no different from the implementation[prior to 9\.10](https://hackage.haskell.org/package/base-4.19.2.0/docs/src/GHC.Exception.Type.html#Exception), but recall that`SomeException`now has an additional field: the exception annotations\. As mentioned above, a pattern match like this will silently discard those annotations\.
### `toException`
The final function in the`Exception`class is`toException`, which is intended to add the`SomeException`wrapper\.
```
class (Typeable e, Show e) => Exception e where
-- (..)
toException :: e -> SomeException
```
Prior to 9\.10, the default implementation literally just added the`SomeException`constructor:
```
-- implementation prior to 9.10
toException = SomeException
```
However, starting in 9\.10 we also need to give an initial value for the exception context\. The default implementation, reasonably enough, chooses the empty context:
```
-- implementation in 9.10, 9.12, 9.14, and 10.0
toException e = let ?exceptionContext = emptyExceptionContext in SomeException e
```
Unfortunately, however, the*documentation*of`toException`has also been modified, and now states that`toException`[should produce a`SomeException`with no attached`ExceptionContext`](https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception.html#v:toException)\. Personally, I think that is a mistake \([\#27194](https://gitlab.haskell.org/ghc/ghc/-/issues/27194)\); we will discuss this in the next session\.
#### ⚠️ Caution: Instance for`SomeException`itself
`SomeException`*itself*is also an instance of`Exception`;`fromException`is trivial, and`backtraceDesired`and`displayException`piggy\-back on the definition of whatever exception is wrapped:
```
instance Exception SomeException where
fromException = Just
backtraceDesired (SomeException e) = backtraceDesired e
displayException (SomeException e) = displayException e
-- (..)
```
The definition of`toException`is more problematic, however\. Prior to 9\.10, calling`toException`on`SomeException`was just an identity:
```
instance Exception SomeException where
-- (..)
-- Prior to 9.10
toException se = se
```
Now, however, the implementation must*clear*the existing context in order to satisfy the contract:
```
instance Exception SomeException where
-- (..)
toException (SomeException e) = let ?exceptionContext = emptyExceptionContext in SomeException e
```
I think this is simply wrong; at the very least, it is highly counter\-intuitive, and it also does not match[the original proposal](https://github.com/ghc-proposals/ghc-proposals/blob/master/proposals/0330-exception-backtraces.rst); I don’t know why this was changed\. We will see some[consequences of this design choice](https://well-typed.com/blog/2026/05/lay-annotation-land/#caution-throwing-someexception)when we discuss throwing exceptions\.
### Newtype helpers
There are two auxiliary types, with their own`Exception`instances, that can be helpful when throwing or catching exceptions in specific ways\. We haven’t discussed either throwing or catching yet, but we will nonetheless discuss these auxiliary types first as we will need them in the subsequent sessions\.
#### `NoBacktrace`
`NoBacktrace`can be used to override`backtraceDesired`:
```
newtype NoBacktrace e = NoBacktrace e
instance Exception e => Exception (NoBacktrace e) where
fromException = fmap NoBacktrace . fromException
toException (NoBacktrace e) = toException e
backtraceDesired _ = False
-- displayException left at its default implementation
```
#### `ExceptionWithContext`
The other, arguably more imporant, auxiliary type is`ExceptionWithContext`\. The definition itself is straight\-forward: it simply pairs some value with an exception context:
```
data ExceptionWithContext a = ExceptionWithContext ExceptionContext a
```
The idea is that this type gives us a way to catch exceptions of specific types \(rather than catching`SomeException`\), and still get access to the exception context\. For example:
```
data MyException = MyException
deriving stock (Show)
deriving anyclass (Exception)
example :: IO ()
example = someAction `catch` \(ExceptionWithContext ctxt MyException) -> do
-- (..)
```
The implementation is reasonably straight\-forward:
```
instance Exception a => Exception (ExceptionWithContext a) where
toException (ExceptionWithContext ctxt e) =
case toException e of
SomeException c ->
let ?exceptionContext = ctxt
in SomeException c
fromException se = do
e <- fromException se
return (ExceptionWithContext (someExceptionContext se) e)
backtraceDesired (ExceptionWithContext _ e) = backtraceDesired e
displayException = displayException . toException
```
That said, the devil is very much in the detail with these kinds of definitions, and as we shall see, it was[defined incorrectly in GHC 9\.10](https://well-typed.com/blog/2026/05/lay-annotation-land/#duplicated-annotations)\.
## Throw
The primary function for throwing an exception is`throwIO`, which is defined as[1](https://well-typed.com/blog/2026/05/lay-annotation-land/#fn1)
```
throwIO :: (HasCallStack, Exception e) => e -> IO a
throwIO e = do
se <- toExceptionWithBacktrace e
IO (PrimOp.raiseIO# se)
```
Most of the actual work happens in`toExceptionWithBacktrace`:
```
toExceptionWithBacktrace :: (HasCallStack, Exception e) => e -> IO SomeException
toExceptionWithBacktrace e =
if backtraceDesired e then do
bt <- Base.collectBacktraces
return (addExceptionContext bt (toException e))
else
return (toException e)
```
That is, if a backtrace is desired, we collect one and add it as an annotation to the exception that we’re about to throw\.
### Generalization
In GHC 9\.14`toExceptionWithBacktrace`was generalized to
```
toExceptionWithBacktrace :: (HasCallStack, Exception e) => e -> IO SomeException
toExceptionWithBacktrace e =
if backtraceDesired e then do
SomeExceptionAnnotation ea <- collectExceptionAnnotation
return (addExceptionContext ea (toException e))
else
return (toException e)
```
This is an experimental API \(not yet part of`base`\); see[CLC \#348](https://github.com/haskell/core-libraries-committee/issues/348)for details\. The idea is that you can use[`setCollectExceptionAnnotation`](https://hackage-content.haskell.org/package/ghc-internal-9.1401.0/docs/GHC-Internal-Exception-Backtrace.html#v:setCollectExceptionAnnotation)to register your own function to be run to construct an annotation whenever an exception is thrown anywhere\. For example, if you’re worried that some IO faults are happening due to your CPU overheating, you might use
```
newtype Temperature = Temperature Int
deriving stock (Show)
deriving anyclass (ExceptionAnnotation)
getTempCPU :: IO Temperature
getTempCPU = -- (..)
main :: IO ()
main = do
setCollectExceptionAnnotation getTempCPU
-- (..)
```
By default, the collection callback is`collectBacktraces`, so unless you register a different callback the behaviour is the same as in 9\.10 and 9\.12\.
### ⚠️ Caution: Throwing`SomeException`
Because`throwIO`calls`toException`, and since`toException`for`SomeException`[*clears the exception context*](https://well-typed.com/blog/2026/05/lay-annotation-land/#instance-for-someexception-itself), you probably don’t want to call`throwIO`on an argument of type`SomeException`: any exception annotations that might be embedded in that exception will be lost\.
The most common case for throwing`SomeException`is*inside*an exception handler; we will cover this specific case of*rethrowing*exceptions[when we discuss`onException`](https://well-typed.com/blog/2026/05/lay-annotation-land/#caution-rethrowing-the-same-exception), but we can reuse the same combinators also to define a general “throw precisely this exception” function:
```
raiseIO :: SomeException -> IO ()
raiseIO (SomeException e) = rethrowIO (ExceptionWithContext ?exceptionContext e)
```
## Catch
The most important change in GHC 9\.12 from 9\.10 is in the definition of`catch`, which now implements the[`WhileHandling`](https://github.com/haskell/core-libraries-committee/issues/202)proposal\. The idea is that when we throw a new exception while handling another, we annotate that new exception*with*the old exception: the new exception arose*while handling*the old exception:
```
data WhileHandling = WhileHandling SomeException deriving Show
catch :: Exception e => IO a -> (e -> IO a) -> IO a
catch (IO io) handler = IO $ PrimOp.catch# io handler'
where
handler' se =
case fromException se of
Just e' -> PrimOp.catch# (unIO (handler e')) (handler'' se)
Nothing -> PrimOp.raiseIO# se
handler'' se se' = PrimOp.raiseIO# (addExceptionContext (WhileHandling se) se')
```
### ⚠️ Caution: Rethrowing the same exception
An important combinator for dealing with exceptions is`onException`, which runs some specified action when an exception occurs \(typically some resource cleanup\) and then rethrows the exception again:
```
onException :: IO a -> IO b -> IO a
onException io what = io `catch` \e -> do
_ <- what
throwIO (e :: SomeException)
```
As written, this is suboptimal: for every layer of`onException`, we re\-throw the annotation stripped from its original annotations \(due to`throwIO`and`toException`for`SomeException`\), and with a new`WhileHandling`annotation with the original exception \(due to`catch`\)\. This result in unnecessary noise: all the information is still there, but it’s buried\. When we*rethrow*the same exception, there is no need for`WhileHanding`: we should just throw the original exception as\-is\.
To solve this,`base`now offers new functions specifically to catch\-and\-rethrow:`catchNoPropagate`[2](https://well-typed.com/blog/2026/05/lay-annotation-land/#fn2)is like the old`catch`, without the handler that adds the`WhileHandling`annotation; and`rethrowIO`, which avoids adding a backtrace \(using[`NoBacktrace`](https://well-typed.com/blog/2026/05/lay-annotation-land/#nobacktrace); moreover, both of these explicitly preserve contexts \(using[`ExceptionWithContext`](https://well-typed.com/blog/exceptionwithcontext)\):
```
catchNoPropagate :: Exception e => IO a -> (ExceptionWithContext e -> IO a) -> IO a
catchNoPropagate (IO io) handler = IO $ PrimOp.catch# io handler'
where
handler' se =
case fromException se of
Just e' -> unIO (handler e')
Nothing -> PrimOp.raiseIO# se
rethrowIO :: Exception e => ExceptionWithContext e -> IO a
rethrowIO e = throwIO (NoBacktrace e)
```
This then enables the following improved implementation of`onException`:
```
onException :: IO a -> IO b -> IO a
onException io what = io `catchNoPropagate` \e -> do
_ <- what
rethrowIO (e :: ExceptionWithContext SomeException)
```
## ⚠️ Caution: Displaying exceptions
The final pitfall we need to discuss is*displaying*exceptions\. Usually we call`displayException`to do so, but this does*not*show annotations\. The idea is that`displayException`is meant to render an exception for*users*, not necessarily*developers*\.[3](https://well-typed.com/blog/2026/05/lay-annotation-land/#fn3)Starting withGHC 9\.14 there is a separate function[`displayExceptionWithInfo`](https://hackage-content.haskell.org/package/base-4.22.0.0/docs/Control-Exception.html#v:displayExceptionWithInfo), but that is not available in GHC 9\.12; moreover, even in GHC 9\.14 I would advise against using it when you are debugging, as*it only shows the top\-level annotations*, making things like[`WhileHandling`](https://well-typed.com/blog/2026/05/lay-annotation-land/#catch)much less useful\.
Personally, I like to use my own custom exception handler which shows the full exception, and makes a few other improvements also: it makes the nesting structure clearer, and reorders annotations to improve readability; you can find an example implementation[on GitHub](https://gist.github.com/edsko/49cc535d712048f6cac532e8a02ea374)\.
## GHC 9\.10
If you cannot upgrade from GHC 9\.10, unfortunately the exception annotation infrastructure has some important limitations\. Upgrade if you can; if not, this section will explain what you need to be aware of\.
### Lost annotations
As we remarked[when we discussed`catch`](https://well-typed.com/blog/2026/05/lay-annotation-land/#catch), the`WhileHandling`proposal only got implemented in GHC 9\.12\. In GHC 9\.10 the definition of`catch`was still unchanged from its definition before the exception annotation proposal:
```
catch :: Exception e => IO a -> (e -> IO a) -> IO a
catch (IO io) handler = IO $ PrimOp.catch# io handler'
where
handler' se =
case fromException se of
Just e' -> unIO (handler e')
Nothing -> PrimOp.raiseIO# se
```
However, the[`Exception`instance for`SomeException`](https://well-typed.com/blog/2026/05/lay-annotation-land/#caution-instance-for-someexception-itself)*was*already changed, so that`toException`clears the exception context\. This means that if an exception with annotations is*ever*caught and rethrown anywhere, in a pattern such as
```
someAction `catch` \(e :: SomeException) -> throwIO e
```
those annotations will be lost\. Similarly, since`onException`had not yet been changed either, any call to`onException`, and by implification`bracket`, anywhere in your callstack would also lose any annotations:
```
bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c
bracket before after thing =
mask $ \restore -> do
a <- before
r <- restore (thing a) `onException` after a
_ <- after a
return r
onException :: IO a -> IO b -> IO a
onException io what = io `catch` \e -> do
_ <- what
throwIO (e :: SomeException)
```
In both cases,`throwIO`*will*insert a new backtrace, but that backtrace will point to where the exception was*rethrown*, not to where it was thrown originally\. What’s worse, neither`bracket`nor`onException`have a`HasCallStack`constraint, so all we see in the callstack is the call to`throwIO`from`onException`itself\.
Cost centre stacks do help a bit here \(provided you enable profiling\): at least you’ll get to see the full backtrace to the exception handler, and with a bit of luck even to the original call to throw, due to the semantics of[semantics of cost centres in exception handlers](https://well-typed.com/blog/2026/05/lay-annotation-land/#cost-centres-vs-exception-handling)\. That won’t always be the case though \(for example, in the case of asynchronous exceptions\), and you won’t see any of the additional annotations that might have been added to the exception\.
### Duplicated annotations
The`Exception`instance for`ExceptionWithContext`in GHC 9\.10 has an incorrect definition for`toException`:
```
instance Exception a => Exception (ExceptionWithContext a) where
-- (..)
-- implementation in GHC 9.10
toException (ExceptionWithContext ctxt e) =
let ?exceptionContext = ctxt in SomeException e
```
\(We saw[the correct definition](https://well-typed.com/blog/2026/05/lay-annotation-land/#exceptionwithcontext)above\.\)
This is wrong for two reasons:
1. It does not use`toException`of the underlying type \(the`a`type parameter\); in*most*cases this does not matter, because`toException`rarely does anything interesting\. Even in the case of`SomeException`, where`toException`does something “interesting” \(if perhaps ill\-advised\), to wit clear the exception context, that doesn’t matter here because we are overriding that context*anyway*\. However, there*might*be types where`toException`genuinely does something important \(even if I am not aware of any such cases currently\)\.
2. In the specific case that`a`*is*`SomeException`, this will create a*nested*`SomeException`:`SomeException \(SomeException someOtherException\)`with two copies of the context \(the annotations\)\.
The second point here is more important: if we later have exception handlers that manipulate the exception context, they will manipulate the outer context but not the inner\. Indeed, if that “manipulation” is “*clear*the context” \(see previous section\), we might end up in the somewhat bizarre situation where these two problems cancel out: if we have
```
someAction `catch` \(ExceptionWithContext ctxt (e :: SomeException))
throwIO $ ExceptionWithContext ctxt e
```
then this exception handler will duplicate the annotations, a later exception handler might lose the outermost annotations \(previous section\) but not the inner, and all of a sudden annotations that were lost mysteriously re\-appear; see GHC ticket[\#27194](https://gitlab.haskell.org/ghc/ghc/-/issues/27194)\.
Unfortunately, this is not a viable workaround for the lost annotation problem, as it changes the type of the exception nested in the \(outer\)`SomeException`from whatever it really should have been to \(the inner\)`SomeException`, which will break any exception handlers for that specific type\.
## GHC 10\.0
The upcoming GHC 10\.0 releases makes a few improvements to the exception annotation infrastructure\. The first important improvement is that exception handling in*STM*was lagging behind a bit; this will be rectified \([\#25365](https://gitlab.haskell.org/ghc/ghc/-/issues/25365)\)\.
The other important fix is in`onException`\. Consider again the definition we saw[when we discussed rethrowing exceptions](https://well-typed.com/blog/2026/05/lay-annotation-land/#caution-rethrowing-the-same-exception):
```
onException :: IO a -> IO b -> IO a
onException io what = io `catchNoPropagate` \e -> do
_ <- what
rethrowIO (e :: ExceptionWithContext SomeException)
```
We mentioned that that`catchNoPropagate`does*not*install an exception handler that installs a`WhileHandling`annotation, because we are rethrowing the very same exception\. However, if`what`throws an exception that is no longer the case\! The definition of`onException`is therefore modified to
```
onException io what = io `catchNoPropagate` \e -> do
_ <- annotateIO (whileHandling e) what
rethrowIO (e :: ExceptionWithContext SomeException)
```
See[CLC Proposal \#397](https://github.com/haskell/core-libraries-committee/issues/397)for details\. As an example, consider what happens if the release callback of`bracket`itself throws an exception:
```
data ReleaseFailed = ReleaseFailed
deriving stock (Show)
deriving anyclass (Exception)
bottom :: HasCallStack => IO ()
bottom = annotateIO (MyAnnotation 123456789) $ throwIO MyException
middle :: HasCallStack => IO ()
middle = bracket (return ()) (\() -> throwIO ReleaseFailed) $ \() -> bottom
top :: HasCallStack => IO ()
top = middle
```
With the new definition`onException`\(and my custom exception display function, which is still needed\), we get
```
demo-bracket-release-fail: Uncaught exception of type ReleaseFailed
ReleaseFailed
HasCallStack backtrace:
throwIO, called at exe/DemoBracketReleaseFail.hs:42:38 in (..)
middle, called at exe/DemoBracketReleaseFail.hs:46:7 in (..)
top, called at exe/DemoBracketReleaseFail.hs:55:5 in (..)
WhileHandling
MyException
MyException
MyAnnotation 123456789
HasCallStack backtrace:
throwIO, called at exe/DemoBracketReleaseFail.hs:38:48 in (..)
bottom, called at exe/DemoBracketReleaseFail.hs:42:70 in (..)
middle, called at exe/DemoBracketReleaseFail.hs:46:7 in (..)
top, called at exe/DemoBracketReleaseFail.hs:55:5 in (..)
```
Very nice\!
## Conclusions
Exception annotations can be invaluable when debugging difficult problems\. While the initial implementation in GHC 9\.10 had some important limitations, the situation has since been much improved\. Provided you use GHC 9\.12 or later, there are two things to pay attention to in your own code \(these apply to 9\.12, 9\.14 and 10\.0\):
- Define your own custom function to display exceptions, which shows*all*annotations, not just the top\-level ones \(or use[mine](https://gist.github.com/edsko/49cc535d712048f6cac532e8a02ea374)\)\.
- Be cautious with throwing`SomeException`:`toException`for`SomeException`will clear the exception context, which is almost certainly not what you want\. For catch\-and\-rethrow, use the[combinators available specifically for that purpose](https://well-typed.com/blog/2026/05/lay-annotation-land/#caution-rethrowing-the-same-exception)\.
That said, there are still a few minor shortcomings to be aware of:
- **GHC 9\.12 and 9\.14**: - Exception handling*in STM*has not yet been updated:`throwSTM`does not collect a backtrace, and`catchSTM`does not add any`WhileHandling`annotations \([\#25365](https://gitlab.haskell.org/ghc/ghc/-/issues/25365)\)\. - `onException`does not add any`WhileHandling`exceptions; as a result, if the resource deallocation callback to`bracket`*itself*throws an exception, the original exception will be lost\. Both of these will be addressed in GHC 10\.0\.
- **exceptions\-0\.10\.9**: this is the version of`exceptions`that is bundled with GHC 9\.12, but lags behind a bit\. For example, the definition of`generalBracket`in[`exceptions\-0\.10\.9`](https://hackage.haskell.org/package/exceptions-0.10.9/docs/src/Control.Monad.Catch.html#generalBracket)does not use any of the abstractions for rethrowing; this is fixed in[`exceptions\-0\.10\.12`](https://hackage-content.haskell.org/package/exceptions-0.10.12/docs/src/Control.Monad.Catch.html#generalBracket)\. The impact is however limited: it merely means that there are some extraneous`WhileHandling`annotations, resulting in unnecessary noise\.
- Any catch\-and\-rethrow patterns implemented in other packages*should*not lose any annotations, provided that they use`catch`from`base`\.
---
1. We will ignore calls to`withFrozenCallStack`, which hide some internal functions from the`HasCallStack`backtrace\. This makes the backtrace slightly more readable, but does not otherwise change anything\. See[CLC \#387](https://github.com/haskell/core-libraries-committee/issues/387)\.[↩︎](https://well-typed.com/blog/2026/05/lay-annotation-land/#fnref1)
2. Some versions of`base`distinguish between`catchExceptionNoPropagate`and`catchNoPropagate`, which differ only in some strictness annotations\. Strictness can make a big difference, especially when IO actions are`undefined`rather than throwing an exception\. However, this is its own can of worms, and outside the scope of this blog post\. See[CLC proposal \#383](https://github.com/haskell/core-libraries-committee/issues/383)for some discussion\.[↩︎](https://well-typed.com/blog/2026/05/lay-annotation-land/#fnref2)
3. In GHC 9\.10,`displayException`*did*show annotaitons, but this got rolled back in 9\.12; see[CLC \#285](https://github.com/haskell/core-libraries-committee/issues/285)for a detailed discussion\.[↩︎](https://well-typed.com/blog/2026/05/lay-annotation-land/#fnref3)