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¶
- Python 3.11+ for
ExceptionGroup,except*andTaskGroup. - TaskGroup error behaviour, from handling ExceptionGroup from TaskGroup.
- Migration context, from migrating from gather to TaskGroup.
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.
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.
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.
Verification¶
except* is used correctly when:
- No plain
except SpecificErrorwraps aTaskGroup. - Each handled type has an
except*clause, and unhandled types propagate. - No
return,breakorcontinueappears inexcept*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 ValueErroraround a TaskGroup. Tested: it missed the group. returninexcept*. ASyntaxErrorat 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.
Related¶
- Exception Groups & TaskGroups — up to the topic overview.
- Wrapping and re-raising ExceptionGroups — building and re-raising groups yourself.
- Resilience, Cancellation & Error Handling — the section overview.