Skip to content

Handling ExceptionGroup from TaskGroup

You migrated a concurrent fan-out from asyncio.gather to asyncio.TaskGroup to get fail-fast cancellation, and now your error handling is silently broken. The try/except ConnectionError that used to catch a downstream failure around gather no longer catches anything — the exception sails straight past it and crashes the request. Nothing changed in the children; what changed is that a TaskGroup never raises a bare exception. It raises an ExceptionGroup that wraps every non-cancellation failure, even when only one task failed, and a plain except clause does not match a group. This guide walks through reproducing the symptom and rewriting the handler with except* so single failures, multiple concurrent failures, unmatched types, and deadline timeouts are all handled correctly.

Splitting an ExceptionGroup with except* A TaskGroup raises a single ExceptionGroup wrapping every child failure; each except* clause peels off the matching subgroup, and whatever is left over is re-raised automatically. except* splits the ExceptionGroup by type ExceptionGroup { ConnectionError, ValueError, OSError } except* ConnectionError handled subgroup except* ValueError handled subgroup unmatched remainder re-raised automatically each clause runs at most once, on the subgroup of matching leaf exceptions

Prerequisites

  • Python 3.11+asyncio.TaskGroup, the except* syntax, and the built-in ExceptionGroup/BaseExceptionGroup types are all 3.11 features.
  • Familiarity with the group's contract — when it cancels siblings and how errors are aggregated — from Exception Groups & TaskGroups and the broader Resilience, Cancellation & Error Handling overview.
  • No third-party libraries; every snippet below is stdlib-only and runnable as-is.

Step 1: Reproduce — a plain except misses the group

Start by confirming the failure. The except ConnectionError looks correct but matches nothing, because what propagates is an ExceptionGroup containing a ConnectionError, not the error itself.

import asyncio


async def call(name: str, fail: bool) -> str:
    await asyncio.sleep(0.01)
    if fail:
        raise ConnectionError(f"{name} down")
    return name


async def main() -> None:
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(call("db", fail=True))
            tg.create_task(call("cache", fail=False))
    except ConnectionError as exc:        # never matches
        print("handled:", exc)


asyncio.run(main())

What to verify: the program does not print handled:. Instead it terminates with ExceptionGroup('unhandled errors in a TaskGroup', [ConnectionError('db down')]). That traceback header — "unhandled errors in a TaskGroup" — is the tell that you are dealing with a group, not a plain exception.

Step 2: Handle it with except* per exception type

Replace except with except*. Each clause receives a sub-group holding only the matching types; iterate .exceptions to reach the individual errors. The two forms cannot be mixed in one try statement — once you use except*, every clause on that statement must use it.

import asyncio


async def call(name: str, fail: bool) -> str:
    await asyncio.sleep(0.01)
    if fail:
        raise ConnectionError(f"{name} down")
    return name


async def main() -> None:
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(call("db", fail=True))
            tg.create_task(call("cache", fail=False))
    except* ConnectionError as eg:
        for exc in eg.exceptions:
            print("handled connection error:", exc)


asyncio.run(main())

What to verify: the program now prints handled connection error: db down and exits cleanly with no traceback. The eg bound by except* is itself an ExceptionGroup; the leaves are in eg.exceptions.

Step 3: Handle multiple concurrent failures

The whole point of a group is that several children can fail in the same scheduling window. Make two tasks fail and confirm your handler sees both, not just the first — the bug a single-exception mindset hides.

import asyncio


async def call(name: str, fail: bool) -> str:
    await asyncio.sleep(0.01)
    if fail:
        raise ConnectionError(f"{name} down")
    return name


async def main() -> None:
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(call("db", fail=True))
            tg.create_task(call("cache", fail=True))
            tg.create_task(call("queue", fail=False))
    except* ConnectionError as eg:
        print(f"{len(eg.exceptions)} connection failure(s)")
        for exc in eg.exceptions:
            print(" -", exc)


asyncio.run(main())

What to verify: the output reports 2 connection failure(s) and lists both db down and queue/cache. If you only ever see one, you are reading a single exception somewhere instead of iterating eg.exceptions.

Step 4: Re-raise and propagate unhandled subgroups

If a child raises a type that no except* clause matches, Python automatically re-raises a residual group containing just the unmatched leaves. This is a safety feature, but you must plan for it — add a final except* Exception to log and decide, rather than letting an unexpected group escape.

import asyncio


async def call(name: str, kind: str) -> str:
    await asyncio.sleep(0.01)
    if kind == "conn":
        raise ConnectionError(f"{name} down")
    if kind == "value":
        raise ValueError(f"{name} bad payload")
    return name


async def main() -> None:
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(call("db", "conn"))
            tg.create_task(call("api", "value"))
    except* ConnectionError as eg:
        print("connection:", [str(e) for e in eg.exceptions])
    except* Exception as eg:                 # catches the unmatched ValueError
        print("other:", [type(e).__name__ for e in eg.exceptions])


asyncio.run(main())

What to verify: both clauses fire — connection: ['db down'] and other: ['ValueError']. Remove the second clause and the ValueError re-raises as a one-element ExceptionGroup, proving unmatched types are never silently dropped.

Step 5: Combine with a timeout (TimeoutError inside the group)

Bound the fan-out with asyncio.timeout(). A clean deadline raises a plain TimeoutError from the context manager, so handle it with a normal except. But if a child also fails before the deadline cancellation settles, a TimeoutError can appear inside the group — so cover both surfaces.

import asyncio


async def slow(name: str, t: float, fail: bool = False) -> str:
    await asyncio.sleep(t)
    if fail:
        raise ConnectionError(f"{name} down")
    return name


async def run(tasks) -> None:
    try:
        async with asyncio.timeout(0.1):
            async with asyncio.TaskGroup() as tg:
                for coro in tasks:
                    tg.create_task(coro)
    except TimeoutError:                      # clean deadline from the CM
        print("deadline exceeded; group cancelled")
    except* ConnectionError as eg:            # a child failed first
        print("child failed:", [str(e) for e in eg.exceptions])
    except* TimeoutError as eg:               # timeout surfaced via a child
        print("timeout inside group:", len(eg.exceptions))


asyncio.run(run([slow("a", 5.0)]))            # -> deadline exceeded
asyncio.run(run([slow("b", 0.01, fail=True), slow("c", 5.0)]))  # -> child failed

What to verify: the first call prints deadline exceeded; group cancelled (plain TimeoutError); the second prints child failed: ['b down'], because the child's exception is what aborts the group before the deadline fires. Keeping both a plain except TimeoutError and an except* set is what makes the handler robust to either ordering.

Verification

Confirm the migration is correct against these observable outcomes:

  • All failures observed. With multiple failing children (Step 3), your logs show a count and one line per leaf, not a single error. Assert len(eg.exceptions) equals the number you expect.
  • Matched subtypes handled. Each except* clause runs at most once and only for its type; the program exits with code 0 when every raised type has a matching clause.
  • Unmatched types re-raised, never swallowed. Temporarily remove a clause and confirm the residual ExceptionGroup propagates with the unmatched leaves intact — silence here would be the bug.
  • Timeout path distinct. A pure deadline surfaces as a plain TimeoutError; a child failure surfaces grouped. Both branches are reachable in tests.

Pitfalls & edge cases

  • You cannot mix except and except* in one statement. SyntaxError results if you try. If you need to catch a non-group exception (like the plain TimeoutError from asyncio.timeout) alongside grouped ones, that plain clause must itself be except on a statement with no except*, or restructure so the group handling is in a nested try.
  • Single vs nested groups. A child that is itself a TaskGroup raises a group, which the parent nests inside its own — eg.exceptions then contains another ExceptionGroup. Use eg.split(SomeError) or a recursive walk over .exceptions to reach deeply nested leaves rather than assuming one level.
  • CancelledError can appear in the group. It is a BaseException, so it lands in a BaseExceptionGroup, and except* Exception will not match it. If you must react to cancellation explicitly, use except* asyncio.CancelledError — but in most code you should let it propagate so the surrounding scope can tear down, per cancellation patterns.
  • .exceptions is a tuple of the immediate children only. It is not flattened. For metrics or logging that must see every leaf regardless of depth, recurse, or call eg.split(predicate) which preserves the nesting in its matched/unmatched halves.
  • An except* T clause runs at most once even for many leaves of type T. Do not put per-error side effects after the loop expecting them to run per exception — iterate eg.exceptions inside the clause.

Frequently Asked Questions

Why doesn't except ConnectionError catch an error from a TaskGroup?

Because TaskGroup wraps every non-cancellation failure, even a single one, in an ExceptionGroup. A plain except ConnectionError does not match an ExceptionGroup containing a ConnectionError. Use except* ConnectionError, which extracts matching exceptions into a sub-group and runs your handler with them.

Can I use except and except* in the same try block?

No. Mixing them in one try statement raises SyntaxError. Once any clause uses except, all clauses on that statement must use except. To also catch a non-group exception, place it on a separate try statement, often a nested one.

How do I make sure I handle every error in a TaskGroup, not just the first?

Iterate eg.exceptions inside each except* clause rather than reading a single exception. For nested groups, where a child is itself a TaskGroup, recurse over .exceptions or use eg.split() so deeply nested leaves are also reached.