Skip to content

Wrapping and Re-raising ExceptionGroups

TaskGroup creates exception groups for you, but plenty of code needs to build its own: collecting failures from gather(..., return_exceptions=True), validating a batch and reporting every problem at once, wrapping errors from several retries, or translating a group at a library boundary. The constructors have rules that surface at the worst moment if you do not know them. Tested on Python 3.14: putting a CancelledError into an ExceptionGroup raised TypeError: Cannot nest BaseExceptions in an ExceptionGroup; an empty list raised ValueError; BaseExceptionGroup given only ordinary exceptions returned an ExceptionGroup automatically. Building a group from three failures out of seven gather results kept every original traceback, and a note added with add_note("item_id=0 tenant=acme") appeared in the formatted traceback. A custom group subclass survived except* splitting only when it defined derive() — without it, the leftover came back as a plain ExceptionGroup. This guide builds, annotates and re-raises groups correctly.

Prerequisites

1. Build a group from collected failures

When work runs to completion and failures are collected rather than raised immediately, report them together as one group:

async def process_all(items) -> list:
    results = await asyncio.gather(*(process(i) for i in items), return_exceptions=True)
    failures = [r for r in results if isinstance(r, Exception)]
    if failures:
        raise ExceptionGroup(f"{len(failures)} of {len(items)} items failed", failures)
    return results

Tested with seven items, three failing: the group read "3 of 7 items failed (3 sub-exceptions)", and every sub-exception still carried its own traceback from where it was raised. Filter on Exception, not BaseException: gather with return_exceptions=True also returns CancelledError instances for cancelled awaitables, and those cannot go into an ExceptionGroup (step 2). The message is the group's summary line in logs, so make it say what failed and how many.

Verify: a test with a mix of failures raises one group whose sub-exceptions match the failed items.

2. Respect the constructor's rules

ExceptionGroup accepts only Exception subclasses and at least one of them; BaseExceptionGroup accepts anything:

ExceptionGroup("x", [asyncio.CancelledError()])
# TypeError: Cannot nest BaseExceptions in an ExceptionGroup

ExceptionGroup("x", [])
# ValueError: second argument (exceptions) must be a non-empty sequence

BaseExceptionGroup("x", [ValueError(1)])                      # -> an ExceptionGroup instance
BaseExceptionGroup("x", [ValueError(1), KeyboardInterrupt()]) # -> a BaseExceptionGroup


def group_or_none(message: str, errors: list[BaseException]) -> BaseExceptionGroup | None:
    return BaseExceptionGroup(message, errors) if errors else None

All four were tested. BaseExceptionGroup(...) picks the right class: it returns an ExceptionGroup when every member is an Exception, and stays a BaseExceptionGroup otherwise, so it is the safe constructor when the list might contain cancellations. Remember that except Exception does not catch a BaseExceptionGroup; a group containing a CancelledError propagates like a cancellation should. Guard against empty lists before constructing — "no failures" is not a group.

Verify: code paths that build groups handle the empty case and lists containing CancelledError in tests.

Exception group constructor rules, tested A grid of 4 rows by 2 columns. Exception group constructor rules, tested call result ExceptionGroup('x', [CancelledError()]) TypeError: cannot nest BaseExceptions ExceptionGroup('x', []) ValueError: must be non-empty BaseExceptionGroup('x', [ValueError()]) returns an ExceptionGroup BaseExceptionGroup('x', [ValueError(), KeyboardInterrupt()]) stays BaseExceptionGroup Use BaseExceptionGroup when the list may contain cancellations.

3. Add context with notes, not by re-wrapping

The failing exception usually lacks the context that makes it actionable — which item, which tenant, which attempt. add_note attaches text that appears in the traceback without changing the exception's type:

async def process_one(item) -> None:
    try:
        await process(item)
    except Exception as exc:
        exc.add_note(f"item_id={item.id} tenant={item.tenant}")
        raise


async def process_all(items) -> None:
    async with asyncio.TaskGroup() as tg:
        for item in items:
            tg.create_task(process_one(item))

Tested: the note "item_id=0 tenant=acme" appeared in the formatted traceback of the group. Because the exception keeps its type, except* clauses still match it, which wrapping it in a new exception type would break. Notes accumulate, so each layer can add its own line — the attempt number in a retry loop, the request id in a handler.

Verify: logged tracebacks of batch failures show which item each sub-exception belongs to.

4. Re-raise and translate without losing information

Inside except*, a bare raise re-raises the matched subgroup; raising a new exception creates a new group with the remainder. Outside, translate deliberately and chain:

class ImportFailed(Exception):
    pass


async def import_file(path: str) -> None:
    try:
        async with asyncio.TaskGroup() as tg:
            for chunk in read_chunks(path):
                tg.create_task(load(chunk))
    except* ValidationError as eg:
        raise ImportFailed(f"{path}: {len(eg.exceptions)} invalid rows") from eg

Tested: raising a new exception inside except* produced a group containing the new exception plus whatever the other clauses did not handle, so translated and unhandled errors travel together. from eg keeps the original group as __cause__, so the full detail is in the logs while the caller deals with one domain error. Avoid raise ... from None for groups: it discards exactly the per-task information that makes them useful.

Verify: the logged traceback for a translated error includes "The above exception was the direct cause" followed by every original sub-exception.

From collected failures to a caller-friendly error A flow of 5 stages. From collected failures to a caller-friendly error collect failures gather / TaskGroup add_note context item, tenant, attempt build group count in message translate at boundary raise X from eg logs keep everything cause chain Callers get one error; logs keep every task's story.

5. Subclass groups with derive()

A custom group type lets callers catch "a batch failure" as a distinct type and carry extra fields. except* and split() create new groups from parts of the original, and they use derive() to do it:

class BatchError(ExceptionGroup):
    def __new__(cls, message: str, excs, batch_id: str | None = None):
        self = super().__new__(cls, message, excs)
        self.batch_id = batch_id
        return self

    def __init__(self, message: str, excs, batch_id: str | None = None):
        super().__init__(message, excs)               # the base __init__ rejects extra arguments

    def derive(self, excs):
        return BatchError(self.message, excs, batch_id=self.batch_id)

Tested: after except* ValueError handled part of a BatchError, the leftover propagated as a BatchError when derive() was defined, and as a plain ExceptionGroup when it was not — so handlers further up that caught BatchError silently stopped matching. Exception groups are constructed in __new__, so put extra attributes there; the base __init__ also rejects extra arguments — tested, BatchError(..., batch_id="B7") raised "takes no keyword arguments" until __init__ was overridden too. Copy the fields in derive(); tracebacks, causes and notes are copied by the runtime. With all three methods in place, the leftover after except* ValueError was a BatchError with batch_id intact.

Verify: a test that splits a custom group checks the type and fields of both parts.

Which group construction fits? A decision on What are you doing with the failures with 4 outcomes. Which group construction fits? What are you doing with the failures? reporting collected Exceptions ExceptionGroup(msg, errs) guard empty list list may contain CancelledError BaseExceptionGroup picks the class adding context exc.add_note() type unchanged custom group type define derive() survives except* Groups keep every failure; notes and causes keep their context.

Verification

Exception groups are built correctly when:

  • Collected failures are filtered to Exception, or BaseExceptionGroup is used.
  • Empty failure lists produce no group.
  • Context is added with add_note, and translations chain with from eg.
  • Custom group subclasses define derive() and are tested through except*.

Diagnostic Hook: in logs, count sub-exceptions per group and the distinct notes attached. Groups with a single sub-exception everywhere suggest a group is being built where a plain exception would do; groups without notes from batch code mean failures cannot be traced back to their items.

Pitfalls & edge cases

  • CancelledError in an ExceptionGroup. Tested: TypeError.
  • Empty groups. Tested: ValueError from the constructor.
  • Subclasses without derive(). Tested: the leftover lost its subclass after except*.
  • raise ... from None on a group. Discards every per-task traceback.

Frequently Asked Questions

How do I create an ExceptionGroup in Python?

Call ExceptionGroup(message, list_of_exceptions) with a non-empty list of Exception instances and raise it. Use BaseExceptionGroup if the list might include BaseException subclasses such as CancelledError.

Why does ExceptionGroup raise TypeError with CancelledError?

CancelledError derives from BaseException, and ExceptionGroup only accepts Exception subclasses. BaseExceptionGroup accepts both and returns an ExceptionGroup when all members are ordinary exceptions.

How do I add context to exceptions inside a group?

Call exc.add_note("...") on each exception before it is grouped; notes appear in tracebacks and do not change the type, so except* still matches.

Why does my custom ExceptionGroup subclass become a plain ExceptionGroup?

except* and split() build new groups through derive(). Override derive() to return your subclass with the same extra fields.