PEP 654: Exception Groups and except* in Python 3.11

Python 3.11 introduced PEP 654, a major enhancement to error handling that allows programs to raise, catch, and inspect multiple unrelated exceptions simultaneously. This article explains the mechanics behind ExceptionGroup and BaseExceptionGroup, details how the new except* syntax allows selective handling of partial exception trees, and demonstrates how these additions simplify error handling in concurrent and asynchronous programming.

The Problem Addressed by PEP 654

Historically, Python could only propagate a single active exception up the call stack at any given time. This model broke down in concurrent contexts—such as asyncio task groups, multi-threaded operations, or parallel worker pools—where multiple operations run concurrently and several can fail independently at the same time. Previously, developers had to resort to suppressing all but one exception, nesting exceptions artificially via __context__ or __cause__, or using custom collection types that did not integrate with native try...except blocks.

Exception Groups: Grouping Multiple Failures

PEP 654 introduced two new built-in exception types: ExceptionGroup and BaseExceptionGroup. These types wrap a list of nested exception instances together with an explanatory message:

eg = ExceptionGroup(
    "Multiple tasks failed",
    [
        ValueError("Invalid configuration value"),
        TypeError("Expected string, got int"),
        KeyError("Missing required key"),
    ],
)

Exception groups can be nested, forming a tree structure where internal nodes are ExceptionGroup instances and leaves are traditional exceptions (or further groups). Because ExceptionGroup subclasses Exception, it only contains standard exceptions. BaseExceptionGroup inherits directly from BaseException and can encapsulate critical system-level interrupts such as KeyboardInterrupt and SystemExit.

Handling Partial Groups with except*

To work with composite exceptions, Python 3.11 introduced the except* (except-star) syntax. A standard except block can only match the entire ExceptionGroup as a whole. In contrast, except* matches and extracts specific exception types from within the group, allowing distinct errors to be addressed separately.

try:
    raise ExceptionGroup(
        "Operation failed",
        [ValueError("Invalid data"), TypeError("Wrong type"), ValueError("Out of range")]
    )
except* ValueError as eg:
    # Handles both ValueError instances wrapped in an ExceptionGroup
    for exc in eg.exceptions:
        print(f"Handled ValueError: {exc}")
except* TypeError as eg:
    # Handles the TypeError wrapped in an ExceptionGroup
    for exc in eg.exceptions:
        print(f"Handled TypeError: {exc}")

Key Execution Rules for except*

  1. Multiple Matches Execute: Unlike traditional try...except, where only the first matching branch executes, multiple except* blocks can execute for a single try block if the raised ExceptionGroup contains exceptions matching different clauses.
  2. Automatic Partitioning: Each except* block receives a newly constructed ExceptionGroup containing only the matched sub-exceptions, preserving their original nested tree hierarchy.
  3. Automatic Re-raising of Unmatched Errors: Any exceptions within the group that do not match any except* clause are automatically bundled into a remaining ExceptionGroup and re-raised at the end of the statement.
  4. Syntax Mutual Exclusivity: You cannot mix except and except* on the same try block.

Through ExceptionGroup and except*, PEP 654 provides Python with native support for composite failures, serving as the foundational error-handling mechanism for modern concurrency frameworks like Python 3.11's asyncio.TaskGroup.