Exception Groups in Python — Core Concepts

Why exception groups matter

Python’s traditional error model is one-at-a-time: raise one exception, catch one exception. This works perfectly for sequential code, but falls apart when multiple operations can fail simultaneously — concurrent tasks, batch validation, multi-step cleanup.

Python 3.11 introduced ExceptionGroup (PEP 654) to handle multiple errors at once, and except* to selectively catch types within a group.

Creating an ExceptionGroup

An ExceptionGroup wraps a list of exceptions with a descriptive message:

errors = ExceptionGroup("validation failed", [
    ValueError("name is required"),
    TypeError("age must be an integer"),
    ValueError("email format invalid"),
])

raise errors

The group carries all three errors as a single exception. Each inner exception retains its full traceback.

Catching with except*

The new except* syntax matches specific exception types from within a group:

try:
    validate_form(data)
except* ValueError as eg:
    print(f"Value errors: {len(eg.exceptions)}")
    for err in eg.exceptions:
        print(f"  - {err}")
except* TypeError as eg:
    print(f"Type errors: {len(eg.exceptions)}")
    for err in eg.exceptions:
        print(f"  - {err}")

Key difference from regular except: multiple except* clauses can fire for the same ExceptionGroup. If the group contains both ValueErrors and TypeErrors, both handlers run. Each handler receives a sub-group containing only the matched exceptions.

except* vs except: the differences

Featureexceptexcept*
MatchesOne exception typeMultiple from a group
Multiple handlers fireNo — first match winsYes — all matching handlers run
ReceivesThe exceptionA sub-group of matching exceptions
Works with ExceptionGroupCatches the whole groupCatches individual types within it
Re-raises unmatchedNo — unmatched is unhandledYes — unmatched types propagate

Where exception groups appear

asyncio.TaskGroup

The primary driver for this feature. When multiple tasks fail in a TaskGroup, the errors are bundled:

import asyncio

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(operation_a())  # raises ValueError
            tg.create_task(operation_b())  # raises ConnectionError
    except* ValueError as eg:
        handle_validation_errors(eg.exceptions)
    except* ConnectionError as eg:
        handle_network_errors(eg.exceptions)

Batch validation

Collect all validation errors before reporting:

def validate_config(config: dict) -> None:
    errors = []
    if "host" not in config:
        errors.append(KeyError("host"))
    if not isinstance(config.get("port"), int):
        errors.append(TypeError("port must be int"))
    if config.get("timeout", 0) < 0:
        errors.append(ValueError("timeout must be positive"))

    if errors:
        raise ExceptionGroup("config validation failed", errors)

Cleanup failures

When multiple cleanup operations can fail:

async def shutdown(resources: list[Resource]) -> None:
    errors = []
    for resource in resources:
        try:
            await resource.close()
        except Exception as e:
            errors.append(e)
    if errors:
        raise ExceptionGroup("shutdown errors", errors)

Splitting and filtering

ExceptionGroup provides methods for programmatic filtering:

group = ExceptionGroup("mixed", [
    ValueError("bad value"),
    ConnectionError("network down"),
    ValueError("another bad value"),
])

value_errors, rest = group.split(ValueError)
# value_errors: ExceptionGroup with 2 ValueErrors
# rest: ExceptionGroup with 1 ConnectionError (or None if empty)

The .subgroup() method returns only the matching exceptions (or None), while .split() returns both matching and non-matching halves.

Common misconception

“ExceptionGroup replaces regular exceptions.”

Regular exceptions are still the right choice when only one error can occur at a time. ExceptionGroup is specifically for situations where multiple independent failures happen and you want to report all of them. Don’t wrap a single exception in a group — that’s overhead for no benefit.

Nesting

ExceptionGroups can nest — a group can contain other groups:

inner = ExceptionGroup("db errors", [ConnectionError("timeout")])
outer = ExceptionGroup("batch failed", [ValueError("bad input"), inner])

The except* syntax handles nesting automatically — except* ConnectionError reaches into nested groups to find matching exceptions.

Compatibility notes

  • Python 3.11+ is required for native ExceptionGroup and except*
  • The exceptiongroup backport package provides ExceptionGroup for Python 3.7+, but except* syntax isn’t available on older versions
  • Libraries like Trio used their own MultiError before PEP 654 standardized the approach

The one thing to remember: ExceptionGroup lets Python report all errors at once instead of just the first one, and except* lets you sort through them by type — essential for concurrent programs where multiple things can fail simultaneously.

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.