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¶
- Python 3.11+.
- Handling groups, from handling specific errors with except*.
- Logging groups, from logging ExceptionGroups with full tracebacks.
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.
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.
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.
Verification¶
Exception groups are built correctly when:
- Collected failures are filtered to
Exception, orBaseExceptionGroupis used. - Empty failure lists produce no group.
- Context is added with
add_note, and translations chain withfrom eg. - Custom group subclasses define
derive()and are tested throughexcept*.
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:
ValueErrorfrom the constructor. - Subclasses without
derive(). Tested: the leftover lost its subclass afterexcept*. raise ... from Noneon 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.
Related¶
- Exception Groups & TaskGroups — up to the topic overview.
- Flattening nested ExceptionGroups — when groups contain groups.
- Resilience, Cancellation & Error Handling — the section overview.