PEP 785 – New methods for easier handling of ExceptionGroups
- Author:
- Zac Hatfield-Dodds <zac at zhd.dev>
- Sponsor:
- Gregory P. Smith <greg at krypto.org>
- Discussions-To:
- Discourse thread
- Status:
- Draft
- Type:
- Standards Track
- Created:
- 08-Apr-2025
- Python-Version:
- 3.14
- Post-History:
- 13-Apr-2025
Abstract
As PEP 654 ExceptionGroup has come into widespread use across the
Python community, some common but awkward patterns have emerged. We therefore
propose adding two new methods to exception objects:
BaseExceptionGroup.leaf_exceptions(), returning the ‘leaf’ exceptions as a list, with each traceback composited from any intermediate groups.BaseException.preserve_context(), a context manager which saves and restores theself.__context__attribute ofself, so that re-raising the exception within another handler does not overwrite the existing context.
We expect this to enable more concise expression of error handling logic in
many medium-complexity cases. Without them, exception-group handlers will
continue to discard intermediate tracebacks and mis-handle __context__
exceptions, to the detriment of anyone debugging async code.
Motivation
As exception groups come into widespread use, library authors and end users often write code to process or respond to individual leaf exceptions, for example when implementing middleware, error logging, or response handlers in a web framework.
Searching GitHub found four implementations of leaf_exceptions() by
various names in the first sixty hits, of which none handle
tracebacks.[1] The same search found thirteen cases where
.leaf_exceptions() could be used. We therefore believe that providing
a method on the BaseException type with proper traceback preservation
will improve error-handling and debugging experiences across the ecosystem.
The rise of exception groups has also made re-raising exceptions caught by an
earlier handler much more common: for example, web-server middleware might
unwrap HTTPException if that is the sole leaf of a group:
except* HTTPException as group:
first, *rest = group.leaf_exceptions() # get the whole traceback :-)
if not rest:
raise first
raise
However, this innocent-seeming code has a problem: raise first will do
first.__context__ = group as a side effect. This discards the original
context of the error, which may contain crucial information to understand why
the exception was raised. In many production apps it also causes tracebacks
to balloon from hundreds of lines, to tens or even hundreds of thousands of
lines - a volume which makes understanding errors far more difficult than
it should be.
A new BaseException.preserve_context() method would be a discoverable,
readable, and easy-to-use solution for these cases.
Specification
A new method leaf_exceptions() will be added to BaseExceptionGroup, with the
following signature:
def leaf_exceptions(self, *, fix_tracebacks=True) -> list[BaseException]:
"""
Return a flat list of all 'leaf' exceptions in the group.
If fix_tracebacks is True, each leaf will have the traceback replaced
with a composite so that frames attached to intermediate groups are
still visible when debugging. Pass fix_tracebacks=False to disable
this modification, e.g. if you expect to raise the group unchanged.
"""
A new method preserve_context() will be added to BaseException, with the
following signature:
def preserve_context(self) -> contextlib.AbstractContextManager[Self]:
"""
Context manager that preserves the exception's __context__ attribute.
When entering the context, the current values of __context__ is saved.
When exiting, the saved value is restored, which allows raising an
exception inside an except block without changing its context chain.
"""
Usage example:
# We're an async web framework, where user code can raise an HTTPException
# to return a particular HTTP error code to the client. However, it may
# (or may not) be raised inside a TaskGroup, so we need to use `except*`;
# and if there are *multiple* such exceptions we'll treat that as a bug.
try:
user_code_here()
except* HTTPException as group:
first, *rest = group.leaf_exceptions()
if rest:
raise # handled by internal-server-error middleware
... # logging, cache updates, etc.
with first.preserve_context():
raise first
Without .preserve_context(), this code would have to either:
- arrange for the exception to be raised after the
except*block, making code difficult to follow in nontrivial cases, or - discard the existing
__context__of thefirstexception, replacing it with anExceptionGroupwhich is simply an implementation detail, or - use
try/exceptinstead ofexcept*, handling the possibility that the group doesn’t contain anHTTPExceptionat all,[2] or - implement the semantics of
.preserve_context()inline; while this is not literally unheard-of, it remains very rare.
Backwards Compatibility
Adding new methods to built-in classes, especially those as widely used as
BaseException, can have substantial impacts. However, GitHub search shows
no collisions for these method names (zero hits[3] and
three unrelated hits respectively). If user-defined methods with these
names exist in private code they will shadow those proposed in the PEP,
without changing runtime behavior.
How to Teach This
Working with exception groups is an intermediate-to-advanced topic, unlikely
to arise for beginning programmers. We therefore suggest teaching this topic
via documentation, and via just-in-time feedback from static analysis tools.
In intermediate classes, we recommend teaching .leaf_exceptions() together
with the .split() and .subgroup() methods, and mentioning
.preserve_context() as an advanced option to address specific pain points.
Both the API reference and the existing ExceptionGroup tutorial
should be updated to demonstrate and explain the new methods. The tutorial
should include examples of common patterns where .leaf_exceptions() and
.preserve_context() help simplify error handling logic. Downstream
libraries which often use exception groups could include similar docs.
We have also designed lint rules for inclusion in flake8-async which will
suggest using .leaf_exceptions() when iterating over group.exceptions
or re-raising a leaf exception, and suggest using .preserve_context() when
re-raising a leaf exception inside an except* block would override any
existing context.
Reference Implementation
While the methods on built-in exceptions will be implemented in C if this PEP is accepted, we hope that the following Python implementation will be useful on older versions of Python, and can demonstrate the intended semantics.
We have found these helper functions quite useful when working with
ExceptionGroups in a large production codebase.
A leaf_exceptions() helper function
import copy
import types
from types import TracebackType
def leaf_exceptions(
self: BaseExceptionGroup, *, fix_tracebacks: bool = True
) -> list[BaseException]: