Exception Groups & except*

Python 3.11 added ExceptionGroup and except* so code can raise and handle several unrelated errors at once, instead of only ever propagating one at a time.

Traditionally, Python can only be 'in the middle of' one exception at a time — a single raise propagates a single exception up the stack. But some operations (running several tasks concurrently, validating several independent fields) can fail in multiple, unrelated ways simultaneously. Python 3.11 introduced ExceptionGroup/BaseExceptionGroup and the except* syntax specifically to represent and handle that case.

The Problem: More Than One Error at Once

Consider running three independent validation checks, or three concurrent async tasks, where more than one can fail. A regular raise can only carry one exception; before exception groups, code either reported only the first failure (losing information about the others) or invented ad hoc lists of errors with no standard handling syntax.

Exception groups are most visible via asyncio.TaskGroup (3.11+), which raises an ExceptionGroup if multiple tasks in the group fail — but the mechanism itself is general-purpose and usable in ordinary synchronous code too.

ExceptionGroup and BaseExceptionGroup

ExceptionGroup(message, exceptions) bundles a list of exceptions into a single exception object that can itself be raised, caught, and inspected. It requires every contained exception to be an Exception subclass (not a BaseException like SystemExit); BaseExceptionGroup is the more general parent that also accepts BaseException subclasses. In practice you almost always use ExceptionGroup.

group.message is 'validation failed' and group.exceptions is the tuple of the two wrapped exceptions; raising it propagates all of them together as one unit.

The default traceback rendering for an unhandled ExceptionGroup nests each sub-exception's own traceback beneath a summary header.

errs = [
    ValueError("bad email"),
    KeyError("missing field: age"),
]
group = ExceptionGroup("validation failed", errs)
raise group
| ExceptionGroup: validation failed (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | ValueError: bad email
    +---------------- 2 ----------------
    | KeyError: 'missing field: age'
    +------------------------------------

Catching with except*

except* (introduced alongside exception groups) matches a specific exception TYPE against the exceptions *inside* a group, splitting the group into a matched part (handled by that clause) and an unmatched remainder (checked against later except* clauses, or re-raised as a new group if nothing matches). Regular except still works but treats the whole group as one opaque object; except* is what lets you handle it by contained type.

Each except* clause receives its own ExceptionGroup (eg) containing only the sub-exceptions that matched its type(s); a single try can have multiple except* clauses, and more than one can run for the same original group.

try:
    raise ExceptionGroup(
        "multiple failures",
        [ValueError("bad email"), KeyError("age"), TypeError("bad id")],
    )
except* ValueError as eg:
    print("value errors:", eg.exceptions)
except* (KeyError, TypeError) as eg:
    print("key/type errors:", eg.exceptions)

except* Splits, It Doesn't Just Filter

A key semantic detail: except* doesn't pick one clause and stop like ordinary except. It partitions the group's contents across every matching clause, and any exceptions matched by NO clause are re-raised (wrapped in a new group) after the try block, rather than being silently dropped.

Because no except* clause matched OSError, it re-propagates (wrapped) after the try/except* block finishes, instead of being lost — nothing goes unhandled silently.

try:
    raise ExceptionGroup("mixed", [ValueError("v"), OSError("o")])
except* ValueError:
    print("handled ValueError")
# OSError was never matched -> propagates in a new ExceptionGroup

Nested Groups

Exception groups can nest: an ExceptionGroup can contain another ExceptionGroup among its exceptions. except* traverses this structure recursively when matching by type, and group.split(SomeType) / group.subgroup(SomeType) are lower-level methods for manually splitting or filtering a group's tree without using try/except* syntax.

split() returns a (matched, remainder) pair, each preserving the original nesting structure but containing only the exceptions that do/don't match.

inner = ExceptionGroup("inner", [ValueError("a")])
outer = ExceptionGroup("outer", [inner, TypeError("b")])

matched, rest = outer.split(ValueError)
print(matched)  # ExceptionGroup('outer', [ExceptionGroup('inner', [ValueError('a')])])
print(rest)     # ExceptionGroup('outer', [TypeError('b')])

Real-World Source: asyncio.TaskGroup

The most common place exception groups appear in practice is asyncio.TaskGroup (3.11+): if multiple tasks started in the group raise exceptions, they are collected and raised together as a single ExceptionGroup once the async with block exits, instead of only reporting whichever task happened to fail first.

Both task failures are captured and reported, rather than the TaskGroup only surfacing whichever one happened to raise first — related concept: asyncio task groups, covered separately.

import asyncio

async def fail_a():
    raise ValueError("a failed")

async def fail_b():
    raise TypeError("b failed")

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(fail_a())
            tg.create_task(fail_b())
    except* ValueError as eg:
        print("ValueErrors:", eg.exceptions)
    except* TypeError as eg:
        print("TypeErrors:", eg.exceptions)

asyncio.run(main())

When to Use Exception Groups Yourself

Reach for ExceptionGroup when a single logical operation can genuinely fail in multiple independent ways that a caller might want to handle separately — batch validation, running several independent sub-tasks, aggregating errors from multiple files. For a single linear operation with one failure mode at a time, plain exceptions and except remain simpler and are still the right tool.

Collecting every validation failure and raising them together gives the caller the full picture in one pass, instead of fixing one error, re-running, and discovering the next one.

def validate_all(record: dict) -> None:
    errors: list[Exception] = []
    if "email" not in record:
        errors.append(ValueError("missing email"))
    if "age" in record and record["age"] < 0:
        errors.append(ValueError("negative age"))
    if errors:
        raise ExceptionGroup("validation failed", errors)

Key Takeaways

ExceptionGroup (Python 3.11+) bundles multiple exceptions into one raisable, catchable object; BaseExceptionGroup is its more general, rarely-used parent.

except* matches a type against the exceptions inside a group, splitting it into matched and unmatched parts across possibly several clauses.

Unmatched exceptions from except* are re-raised (wrapped in a new group) after the try block, never silently dropped.

Exception groups nest; group.split()/subgroup() give lower-level access to the tree without try/except* syntax.

asyncio.TaskGroup is the most common real-world source of exception groups, reporting every failing task's exception together.

try: raise ExceptionGroup("e", [ValueError("v")]) except ValueError: pass # never matches try: raise ExceptionGroup("e", [ValueError("v")]) except* ValueError: pass

Plain except ValueError does not match an ExceptionGroup even if it contains a ValueError inside — the group itself is not a ValueError instance. Use except* to match by contained type.

except* ValueError: handle() # assumes this is the only possible failure type except* ValueError: handle_value_errors() except* Exception: handle_everything_else()

Any exceptions in the group not matched by any except* clause propagate after the try block instead of vanishing — add a catch-all except* clause if you intend to handle every possible sub-exception.

Why were exception groups added to Python, given that raise/except already existed?

Ordinary raise/except can only represent one exception in flight at a time; exception groups let an operation that fails in multiple independent ways (batch validation, concurrent tasks) report and be handled for all of them together, instead of only the first.

What's the difference between except SomeType and except* SomeType when handling an ExceptionGroup?

Plain except matches only if the exception itself (the group object) is an instance of SomeType, which is rarely useful. except* looks inside the group and matches SomeType against its contained exceptions, splitting the group into matched and unmatched parts.

What happens to exceptions in a group that no except* clause matches?

They are not silently dropped — they are re-raised, wrapped in a new exception group, after the try/except* block finishes.

What is the difference between ExceptionGroup and BaseExceptionGroup?

ExceptionGroup requires all wrapped exceptions to be Exception subclasses. BaseExceptionGroup is the more general parent that also allows BaseException subclasses like SystemExit or KeyboardInterrupt; ExceptionGroup is what you use for ordinary application errors.

Where do exception groups most commonly appear in real async code?

asyncio.TaskGroup (3.11+): if multiple tasks inside the group raise, their exceptions are collected and raised together as one ExceptionGroup when the async with block exits.

Can a single try/except* statement have multiple except* clauses run for the same raised group?

Yes — except* splits the group's contents across every matching clause, so more than one clause can execute if the group contains exceptions of multiple matched types.

What does this print?

It prints 'A 1' then 'B 2'. The ValueError is matched by the first clause (1 exception); KeyError and TypeError are both matched by the second clause (2 exceptions).

Write a function that runs three independent checks and, if any fail, raises them together as one ExceptionGroup instead of stopping at the first failure.

def run_checks(value: int) -> None: errors: list[Exception] = [] if value < 0: errors.append(ValueError("value must be non-negative")) if value % 2 != 0: errors.append(ValueError("value must be even")) if value > 1000: errors.append(ValueError("value too large")) if errors: raise ExceptionGroup("check failures", errors)

Each check appends its own exception instead of raising immediately; if any failed, all of them are raised together in one ExceptionGroup so the caller sees every problem at once.

Write a try/except* block that catches OSError sub-exceptions from a group by logging them, and lets any other exception types in the group propagate.

try: do_batch_io() except* OSError as eg: for exc in eg.exceptions: log.error("I/O failure: %s", exc)

Only OSError sub-exceptions are matched and logged; any other exception types inside the original group are not matched by this clause and re-propagate wrapped in a new group after the try block.

try:
    raise ExceptionGroup("g", [ValueError("v"), KeyError("k"), TypeError("t")])
except* ValueError as eg:
    print("A", len(eg.exceptions))
except* (KeyError, TypeError) as eg:
    print("B", len(eg.exceptions))