Skip to content

Handling Specific Errors with except*

A TaskGroup reports failures as an ExceptionGroup, so a plain except ValueError: around it never matches — the exception raised is the group, not the ValueError inside it. except* is the syntax for handling groups: each clause takes the matching exceptions out of the group, and whatever is left keeps propagating. Tested on Python 3.14: a group from three failed tasks — two ValueErrors and a KeyError — was split so that an except* ValueError clause received both ValueErrors and an outer handler received the KeyError alone. A plain except ValueError around the same TaskGroup did not catch a single failing task's ValueError; the ExceptionGroup passed straight through it. And return, break and continue inside an except* block are a SyntaxError. This guide uses except* precisely, including the parts that surprise people.

Prerequisites

1. Know why plain except misses TaskGroup errors

Even when exactly one task fails, the TaskGroup raises an ExceptionGroup containing it:

try:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch_user(42))         # raises ValueError
except ValueError:
    ...                                         # never runs: the exception is ExceptionGroup

Tested: the except ValueError clause was skipped and an ExceptionGroup containing (ValueError('x'),) propagated. except Exception: does match, because ExceptionGroup is a subclass of Exception — which is how code migrated from gather can appear to work while its specific handlers have silently stopped running. Search for except SomeError around TaskGroup blocks when migrating; each one needs to become except*.

Verify: a test that makes one task raise each handled error type confirms the intended handler runs.

2. Handle each error type with its own except* clause

Each except* clause receives an ExceptionGroup of just the matching exceptions; several clauses can run for the same group:

try:
    async with asyncio.TaskGroup() as tg:
        for item in batch:
            tg.create_task(process(item))
except* ValidationError as eg:
    for exc in eg.exceptions:
        log.warning("invalid item: %s", exc)
    metrics.invalid.inc(len(eg.exceptions))
except* TimeoutError as eg:
    log.error("%d items timed out", len(eg.exceptions))
    raise                                       # re-raise just the timeouts

Tested with two ValueErrors and a KeyError: the except* ValueError clause got a group of both ValueErrors, and the KeyError continued to an outer handler on its own. Unlike except, every matching clause runs — a group with both ValidationError and TimeoutError runs both clauses — and unmatched exceptions are re-raised automatically as a group after all clauses finish. A bare raise inside a clause re-raises that clause's subgroup.

Verify: a test that raises one of every handled type in a single group sees each clause run once.

How except* splits a group A flow of 4 stages. How except* splits a group TaskGroup raises [ValueError, ValueError, KeyError] except* ValueError gets both ValueErrors leftover [KeyError] re-raised as group to the outer handler Tested: two ValueErrors handled together, the KeyError passed on alone.

3. Respect what except* blocks cannot do

except* blocks may run more than once per try statement (once per matching clause), so control flow out of them is restricted. return, break and continue are rejected by the compiler:

# intentional SyntaxError: 'break', 'continue' and 'return' cannot appear in an except* block
async def first_valid(items):
    try:
        async with asyncio.TaskGroup() as tg:
            ...
    except* ValueError:
        return None                    # not allowed


# Set a result, then return after the try statement
async def first_valid(items):
    result = None
    try:
        async with asyncio.TaskGroup() as tg:
            ...
    except* ValueError as eg:
        result = fallback_for(eg)
    return result

Tested on Python 3.11 to 3.14: a return inside except* failed at compile time with that exact message, so it surfaces on import rather than in production — but it is easy to write when converting except blocks. Raising a different exception inside an except* clause is allowed: tested, raising RuntimeError inside except* ValueError produced a new group containing the RuntimeError and the unhandled remainder (KeyError), so translated and unhandled errors propagate together.

Verify: every except* block in the codebase ends without return, break or continue; the compiler enforces it, and a linter rule can flag it earlier.

except* behaviour, tested on Python 3.14 A grid of 6 rows by 2 columns. except* behaviour, tested on Python 3.14 situation result plain except ValueError around TaskGroup missed; ExceptionGroup propagated except* ValueError on [V, V, KeyError] both ValueErrors in one subgroup unhandled KeyError re-raised as its own group naked ValueError in try with except* wrapped: ExceptionGroup('', [ValueError]) raise RuntimeError inside except* new group: [RuntimeError, [KeyError]] return inside except* SyntaxError at compile time except* always deals in groups, even for a single exception.

4. Translate groups into API-level errors

At a service boundary, callers usually want one error, not a group. Decide the policy explicitly — first error, most severe error, or a summary — in one place:

class BatchFailed(Exception):
    def __init__(self, failures: list[BaseException]) -> None:
        super().__init__(f"{len(failures)} items failed")
        self.failures = failures


async def process_batch(items) -> None:
    try:
        async with asyncio.TaskGroup() as tg:
            for item in items:
                tg.create_task(process(item))
    except* ValidationError as eg:
        raise BadRequest([str(e) for e in eg.exceptions]) from eg      # caller's fault: 400
    except* Exception as eg:
        raise BatchFailed(list(eg.exceptions)) from eg                 # our fault: 500

from eg keeps the original group as the cause, so logs still show every task's traceback, as in logging ExceptionGroups with full tracebacks. Note that except* Exception matches everything left after earlier clauses, and that a naked exception raised in the try body is wrapped into a group too — tested, a bare ValueError reached except* ValueError as ExceptionGroup('', [ValueError('naked')]) — so handlers always receive groups.

Verify: the API returns 400 for validation failures, 500 for others, and logs include each underlying traceback.

5. Use split and subgroup for programmatic handling

When the decision depends on more than type — an error code, a retryable flag — work with the group directly:

def is_retryable(exc: BaseException) -> bool:
    return isinstance(exc, (TimeoutError, ConnectionError)) or getattr(exc, "retryable", False)


try:
    async with asyncio.TaskGroup() as tg:
        for job in jobs:
            tg.create_task(run(job))
except ExceptionGroup as eg:
    retryable, permanent = eg.split(is_retryable)        # predicate, not just types
    if retryable:
        await schedule_retries(retryable)
    if permanent:
        raise permanent

split() returns two groups (or None) partitioned by a type or predicate, preserving nesting; subgroup() returns only the matching part. Tested on a nested group of ValueError, TypeError and an inner group of ValueError and OSError: split(ValueError) returned two groups of two, and subgroup(OSError) a group of one, with nesting kept. Predicates make it possible to route errors by attributes that except* cannot express.

Verify: a test with a mixed group checks that retryable errors are scheduled and permanent ones raised.

How should this TaskGroup failure be handled? A decision on What decides the handling with 4 outcomes. How should this TaskGroup failure be handled? What decides the handling? exception type except* clauses several may run attributes, retryability eg.split(predicate) two groups API boundary translate, raise ... from eg one error out nothing applies let it propagate still a group Never use plain except for a specific type around a TaskGroup.

Verification

except* is used correctly when:

  • No plain except SpecificError wraps a TaskGroup.
  • Each handled type has an except* clause, and unhandled types propagate.
  • No return, break or continue appears in except* blocks.
  • Service boundaries translate groups into single errors with the group as cause.

Diagnostic Hook: log the number of exceptions per group and their types whenever a TaskGroup fails. Groups containing many exceptions of one type point at a shared cause, such as a dependency outage; groups whose types never reach a matching handler point at plain except clauses left over from gather code.

Pitfalls & edge cases

  • Plain except ValueError around a TaskGroup. Tested: it missed the group.
  • return in except*. A SyntaxError at compile time.
  • Assuming one clause runs. Every matching except* clause runs.
  • Losing tracebacks when translating. Raise the new error from eg.

Frequently Asked Questions

Why doesn't except ValueError catch errors from a TaskGroup?

TaskGroup raises an ExceptionGroup even for a single failure, and ExceptionGroup is not a ValueError. Use except* ValueError, which matches ValueErrors inside the group.

Can I return from an except* block?

No. return, break and continue inside except* are a SyntaxError. Set a variable in the clause and return after the try statement.

What happens to exceptions that no except* clause matches?

They are re-raised automatically as an ExceptionGroup after all matching clauses have run; in testing a KeyError passed on alone after the ValueErrors were handled.

How do I handle an ExceptionGroup by a condition other than type?

Use eg.split(predicate) to partition it into matching and non-matching groups, or eg.subgroup(predicate) to keep only the matches.