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:

  • ExceptionGroup inherits from both BaseExceptionGroup and Exception, so except Exception catches it
  • BaseExceptionGroup exists for groups containing BaseException subclasses (like KeyboardInterrupt)
  • The constructor enforces: ExceptionGroup can only wrap Exception subclasses; use BaseExceptionGroup for 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:

  1. Python calls .split(ExceptionType) on the exception group
  2. The matching sub-group (if any) is bound to the as variable
  3. The handler executes
  4. Any remaining unmatched exceptions continue to the next except* clause
  5. 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.

pythonerror-handlingpython311

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.