Exception Groups in Python — Deep Dive
Technical perspective
PEP 654 introduced the most fundamental change to Python’s exception model since PEP 3134 added exception chaining in Python 3.0. Exception groups solve a structural problem: Python’s call stack is a tree, but its error model was linear — one exception at a time. When concurrency introduces multiple failure paths, a linear model loses information. ExceptionGroup makes the error model match the execution model.
The class hierarchy
BaseException
├── Exception
│ └── ... (ValueError, TypeError, etc.)
├── BaseExceptionGroup(BaseException)
│ └── ExceptionGroup(BaseExceptionGroup, Exception)
└── KeyboardInterrupt, SystemExit, etc.
Key design decisions:
ExceptionGroupinherits from bothBaseExceptionGroupandException, soexcept Exceptioncatches itBaseExceptionGroupexists for groups containingBaseExceptionsubclasses (likeKeyboardInterrupt)- The constructor enforces:
ExceptionGroupcan only wrapExceptionsubclasses; useBaseExceptionGroupfor anything else
# This raises TypeError:
ExceptionGroup("bad", [KeyboardInterrupt()])
# This works:
BaseExceptionGroup("ok", [KeyboardInterrupt()])
The derive mechanism
When you split or subgroup an ExceptionGroup, Python needs to create new group instances. Subclasses can control this via derive():
class RetryableErrors(ExceptionGroup):
def derive(self, excs):
return RetryableErrors(self.message, excs)
errors = RetryableErrors("network issues", [
ConnectionError("timeout"),
ConnectionError("refused"),
ValueError("bad response"),
])
network, other = errors.split(ConnectionError)
print(type(network)) # <class 'RetryableErrors'> — preserved!
Without derive(), splitting returns a plain ExceptionGroup, losing your custom subclass. The derive method lets frameworks maintain their exception taxonomy through split operations.
except* compilation model
The except* statement compiles differently from regular except. For each except* clause:
- Python calls
.split(ExceptionType)on the exception group - The matching sub-group (if any) is bound to the
asvariable - The handler executes
- Any remaining unmatched exceptions continue to the next
except*clause - After all clauses, if unmatched exceptions remain, they’re re-raised as a new group
try:
risky_operation()
except* ValueError as eg:
handle_values(eg) # Runs if any ValueError in group
except* TypeError as eg:
handle_types(eg) # Runs if any TypeError in group
# Unmatched exceptions (e.g., KeyError) propagate automatically
This is fundamentally different from except where the first matching handler wins and the rest are skipped.
Nested exception group handling
Groups can contain other groups, creating a tree structure:
inner1 = ExceptionGroup("db", [ConnectionError("db timeout")])
inner2 = ExceptionGroup("cache", [ConnectionError("redis down")])
outer = ExceptionGroup("service failed", [
ValueError("bad input"),
inner1,
inner2,
])
The split() and subgroup() methods operate recursively:
conn_errors, rest = outer.split(ConnectionError)
# conn_errors contains both ConnectionErrors (from inner1 and inner2)
# rest contains only the ValueError
# Structure is preserved:
print(conn_errors)
# ExceptionGroup('service failed', [
# ExceptionGroup('db', [ConnectionError('db timeout')]),
# ExceptionGroup('cache', [ConnectionError('redis down')])
# ])
The nesting hierarchy is maintained — split doesn’t flatten the tree.
Pattern: comprehensive error collection
from dataclasses import dataclass
@dataclass
class ValidationError(Exception):
field: str
message: str
@dataclass
class BusinessRuleError(Exception):
rule: str
message: str
def validate_order(order: dict) -> None:
field_errors: list[ValidationError] = []
rule_errors: list[BusinessRuleError] = []
# Field validation
if not order.get("customer_id"):
field_errors.append(ValidationError("customer_id", "required"))
if not isinstance(order.get("total"), (int, float)):
field_errors.append(ValidationError("total", "must be numeric"))
if not order.get("items"):
field_errors.append(ValidationError("items", "at least one item required"))
# Business rules
if order.get("total", 0) > 10000 and not order.get("approved"):
rule_errors.append(BusinessRuleError(
"high_value_approval", "orders over $10,000 require approval"
))
if order.get("rush") and order.get("shipping") == "economy":
rule_errors.append(BusinessRuleError(
"rush_shipping", "rush orders cannot use economy shipping"
))
all_errors = field_errors + rule_errors
if all_errors:
raise ExceptionGroup(f"order validation: {len(all_errors)} errors", all_errors)
The caller can then handle each category:
try:
validate_order(order)
except* ValidationError as eg:
return {"status": "error", "field_errors": [
{"field": e.field, "message": e.message} for e in eg.exceptions
]}
except* BusinessRuleError as eg:
return {"status": "error", "rule_violations": [
{"rule": e.rule, "message": e.message} for e in eg.exceptions
]}
Pattern: retry with partial success
async def fetch_many(urls: list[str]) -> tuple[list[dict], list[Exception]]:
results = []
all_errors = []
for attempt in range(3):
pending = [u for u in urls if u not in {r["url"] for r in results}]
if not pending:
break
try:
async with asyncio.TaskGroup() as tg:
tasks = {
url: tg.create_task(fetch(url))
for url in pending
}
except* Exception as eg:
for exc in eg.exceptions:
all_errors.append(exc)
else:
for url, task in tasks.items():
results.append({"url": url, "data": task.result()})
return results, all_errors
Interaction with context managers
ExceptionGroups interact carefully with __exit__ and __aexit__:
class ManagedResource:
def __exit__(self, exc_type, exc_val, exc_tb):
if isinstance(exc_val, ExceptionGroup):
# Can inspect, filter, or suppress
_, remaining = exc_val.split(TransientError)
if remaining is None:
return True # Suppress all (only transient errors)
return False
A context manager can suppress an entire ExceptionGroup or specific sub-types by returning True after filtering.
Migration from try/except
Before (single exception):
try:
result = process(data)
except ValueError as e:
log_error(e)
result = default_value
After (group-aware):
try:
result = process(data)
except* ValueError as eg:
for e in eg.exceptions:
log_error(e)
result = default_value
except ValueError as e: # Still catches non-grouped ValueErrors
log_error(e)
result = default_value
You can mix except and except* in the same try block. Regular except catches non-grouped exceptions; except* catches grouped ones.
Performance considerations
ExceptionGroup creation is slightly more expensive than a single exception — it allocates a tuple of sub-exceptions and validates types. However, exceptions are already the slow path in Python (raising an exception is ~10x slower than normal flow control), so the added overhead is negligible in practice.
The recursive split() and subgroup() operations have O(n) complexity where n is the total number of leaf exceptions in the tree. For typical group sizes (2–50 exceptions), this is sub-microsecond.
Compatibility with existing code
The biggest migration concern: code that catches Exception will also catch ExceptionGroup. This means existing exception handlers might receive groups they don’t expect:
# This existing code now catches ExceptionGroups too
try:
await taskgroup_operation()
except Exception as e:
log.error(f"Failed: {e}") # Logs the group, not individual errors
This is by design — ExceptionGroup inherits from Exception so it doesn’t silently bypass existing handlers. But handlers that assume a single error should be updated to check for groups:
try:
await taskgroup_operation()
except ExceptionGroup as eg:
for exc in eg.exceptions:
log.error(f"Failed: {exc}")
except Exception as e:
log.error(f"Failed: {e}")
The one thing to remember: ExceptionGroup transforms Python’s error model from linear to tree-shaped — matching the reality of concurrent execution where multiple independent operations can fail, each with their own cause, traceback, and recovery strategy.
See Also
- Python 310 New Features Python 3.10 gave programmers a shape-sorting machine, friendlier error messages, and cleaner ways to say 'this or that' in type hints.
- Python 311 New Features Python 3.11 made everything faster, error messages smarter, and let you catch several mistakes at once instead of stopping at the first one.
- Python 312 New Features Python 3.12 made type hints shorter, f-strings more powerful, and started preparing Python's engine for a world without the GIL.
- Python 313 New Features Python 3.13 finally lets multiple tasks run at the same time for real, added a speed booster engine, and gave the interactive prompt a colourful makeover.
- Python Free Threading Nogil Python has always had a rule that only one thing can happen at a time — free threading finally changes that, like opening extra checkout lanes at the grocery store.