Skip to content

Flattening Nested ExceptionGroups

Nested TaskGroups produce nested exception groups: a job that fans out to shards, each of which fans out to tasks, fails with a group of groups. That structure is accurate, and awkward for everything downstream — log summaries, metrics, API errors, alert messages. Tested with a job of three shards, each with two failing tasks: the top-level group's message read "unhandled errors in a TaskGroup (3 sub-exceptions)" although it held 6 leaf errors two levels deep, and its formatted traceback ran to 71 lines. except* handled the nesting without help — except* TimeoutError received all 3 timeouts, nested structure preserved. A flattened group of the six leaves read "(6 sub-exceptions)", kept each leaf's own traceback, and formatted in 26 lines — but lost the frames showing which shard each failure came from. This guide flattens for reporting while keeping the information that flattening throws away.

Prerequisites

1. See where nesting comes from

Every TaskGroup wraps its children's failures in a group. When a child itself runs a TaskGroup, its failure is already a group, and it is wrapped again:

async def shard(n: int) -> None:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(load_rows(n))           # raises ValueError
        tg.create_task(write_index(n))         # raises TimeoutError


async def job() -> None:
    async with asyncio.TaskGroup() as tg:
        for n in range(3):
            tg.create_task(shard(n))

# raises ExceptionGroup("unhandled errors in a TaskGroup", [
#   ExceptionGroup(..., [ValueError, TimeoutError]),   # shard 0
#   ExceptionGroup(..., [ValueError, TimeoutError]),   # shard 1
#   ExceptionGroup(..., [ValueError, TimeoutError]),   # shard 2
# ])

Tested: three top-level members, nesting depth two, six leaves. The top-level message counts direct members, not leaves, so "3 sub-exceptions" understates the damage by half — a summary built from str(eg) or len(eg.exceptions) misreports every nested failure.

Verify: log both len(eg.exceptions) and the leaf count for a failing nested job; they differ.

The group raised by three failing shards A decision on job: ExceptionGroup (3 sub-exceptions) with 3 outcomes. The group raised by three failing shards job: ExceptionGroup (3 sub-exceptions) shard 0 group ValueError, TimeoutError 2 leaves shard 1 group ValueError, TimeoutError 2 leaves shard 2 group ValueError, TimeoutError 2 leaves The top-level message counts 3; there are 6 failures.

2. Iterate the leaves

A small generator walks the tree and yields every non-group exception, which is what reporting code almost always wants:

from collections.abc import Iterator


def leaves(exc: BaseException) -> Iterator[BaseException]:
    if isinstance(exc, BaseExceptionGroup):
        for inner in exc.exceptions:
            yield from leaves(inner)
    else:
        yield exc


def summarize(eg: BaseExceptionGroup) -> dict[str, int]:
    counts: dict[str, int] = {}
    for exc in leaves(eg):
        counts[type(exc).__name__] = counts.get(type(exc).__name__, 0) + 1
    return counts            # {"ValueError": 3, "TimeoutError": 3}

The leaves are the original exception objects, with their own tracebacks and notes, so nothing about the individual failures is lost by iterating. Use summarize for metrics and alert text — "3 ValueError, 3 TimeoutError" says more than "3 sub-exceptions". For handling by type, prefer except*, which already matches leaves at any depth (step 4).

Verify: the leaf count from leaves() equals the number of failed tasks in a test with nested groups.

3. Flatten for reports, with the path recorded

A flat group is easier to log and to render as an API error. Flattening discards the intermediate groups — and their tracebacks, which show where in the task tree each failure happened — so record the path as a note first:

def flatten(eg: BaseExceptionGroup, message: str | None = None) -> BaseExceptionGroup:
    flat: list[BaseException] = []

    def walk(exc: BaseException, path: tuple[str, ...]) -> None:
        if isinstance(exc, BaseExceptionGroup):
            for i, inner in enumerate(exc.exceptions):
                walk(inner, path + (f"{exc.message}[{i}]",))
        else:
            exc.add_note("path: " + " > ".join(path))       # keep where it came from
            flat.append(exc)

    walk(eg, ())
    return BaseExceptionGroup(message or eg.message, flat)

Tested: the flattened group of six leaves read "(6 sub-exceptions)", every leaf kept its traceback, and the formatted output shrank from 71 to 26 lines because the nested groups' own frames were gone. Those frames are what told you which shard failed — hence the path note. Better still, name the groups' tasks or add a note at each level (for example exc.add_note(f"shard={n}") in the shard), so the leaves carry business context rather than positions.

Verify: each leaf in a flattened group's traceback shows a note identifying its shard or item.

Formatted traceback lines for six failures 2 horizontal bars comparing nested group (3 shards x 2) with the others. Formatted traceback lines for six failures nested group (3 shards x 2) 71 lines flattened to 6 leaves 26 lines Python 3.14; flattening keeps each leaf's own traceback but drops the shard groups' frames. Flat is shorter; add notes so it is not less informative.

4. Let except* handle nesting where possible

For control flow — retry these, report those, re-raise the rest — there is usually no need to flatten. except* matches leaves at any depth and keeps the structure of what it passes on:

try:
    await job()
except* TimeoutError as eg:
    # tested: receives all 3 TimeoutErrors, still nested by shard (depth 2)
    await schedule_retry([leaf for leaf in leaves(eg)])
except* ValueError as eg:
    report_invalid_rows(list(leaves(eg)))

Tested: except* TimeoutError received the three timeouts from three different shards, inside a group that kept the shard nesting; except* ValueError got the other three. Because the matched subgroup keeps its structure, the per-shard frames are still available in the handler. Flatten only at the edges — logs, metrics, API responses — and let except* and split() handle the logic.

Verify: handlers written with except* work unchanged when another level of TaskGroup nesting is added.

5. Avoid creating nesting you do not need

Some nesting is incidental: a helper that runs a single-task TaskGroup, or a retry wrapper that collects attempts into a group. Each level adds a wrapper for every consumer to unpick:

# Incidental nesting: a TaskGroup around a single task
async def fetch_one(url):
    async with asyncio.TaskGroup() as tg:
        task = tg.create_task(fetch(url))
    return task.result()                     # a failure now arrives as a group of one

# Simpler: await it directly; failures arrive as the plain exception
async def fetch_one(url):
    return await fetch(url)

Reserve TaskGroup for real fan-out. When an inner group is caught and handled completely, re-raise a single meaningful exception (or nothing) rather than the group, so outer layers see one level less. And at a library boundary, decide whether callers get groups at all: many APIs are clearer raising one domain error with the group as its cause, as in wrapping and re-raising ExceptionGroups.

Verify: the maximum nesting depth of groups in production logs matches the real fan-out depth of your code.

What should this code do with a nested group? A decision on What is the group for with 4 outcomes. What should this code do with a nested group? What is the group for? decide by type except* / split keep the tree metrics, alert text iterate leaves count by type logs, API error flatten + path notes short, still traceable incidental nesting remove at source await directly Keep the tree for logic; flatten at the edges.

Verification

Nested groups are handled well when:

  • Counts and summaries use leaves, not len(eg.exceptions).
  • Flattening records where each leaf came from, through notes.
  • Control flow uses except* or split(), which handle nesting natively.
  • Incidental nesting is removed at its source.

Diagnostic Hook: log, for every failed TaskGroup at the top of a request or job, the leaf count by type and the nesting depth. A depth larger than your fan-out structure indicates incidental groups; a leaf count much larger than the top-level count shows how much a message-only log would have hidden.

Pitfalls & edge cases

  • Trusting the top-level count. Tested: "3 sub-exceptions" for 6 failures.
  • Flattening without notes. The shard or item each failure came from is lost.
  • Flattening for control flow. except* already handles nesting and keeps context.
  • Single-task TaskGroups. They add a level of nesting for no concurrency.

Frequently Asked Questions

Why are my ExceptionGroups nested?

Each TaskGroup wraps its children's failures in a group; when a child runs its own TaskGroup, its group is wrapped again. Three shards with two failures each produced a two-level group in testing.

How do I flatten a nested ExceptionGroup?

Recursively collect the non-group exceptions and build a new group from them, adding a note to each leaf with the path or business context, since the intermediate groups' frames are dropped.

Does except* work with nested ExceptionGroups?

Yes. It matches leaves at any depth and passes the matched subgroup with its nesting preserved; in testing except* TimeoutError received all three timeouts from three shards.

How do I count failures in a nested ExceptionGroup?

Iterate its leaves rather than using len(eg.exceptions), which counts only direct members.