Sequential code has a convenient property: at any moment, at most one thing has
gone wrong. The entire design of try/except assumes it. Concurrency breaks
the assumption. Run ten tasks together, have four of them fail differently, and
except forces you to pick one and lose three.
For years the workaround was to pick the first, or the “most important”, or to log the rest and re-raise one. All of those are lossy, and the loss always lands on the thing you most needed to see. PEP 654 fixed it in Python 3.11 by making a failure able to be a tree rather than a value.
Two classes, and the difference is the point
ExceptionGroup("upload failed", [TimeoutError(), ValueError()])
-
ExceptionGroupmay contain onlyExceptionmembers, and is itself anException. Soexcept Exceptioncatches it, which is what you want: a boundary handler that already catchesExceptionkeeps working when the code underneath starts raising groups. -
BaseExceptionGroupmay contain anything. When it holds a member that is not anException, it is deliberately not anException— soexcept Exceptionwill not catch it.
That second rule is not fussiness, it is the whole reason the split exists.
asyncio.CancelledError is a BaseException precisely so that blanket handlers
cannot eat a cancellation. If a group containing a CancelledError were itself
catchable by except Exception, the group would have re-opened the hole that
moving CancelledError out of Exception closed.
The constructor picks for you:
BaseExceptionGroup("x", [ValueError()]) # -> ExceptionGroup
BaseExceptionGroup("x", [ValueError(), Cancel()]) # -> BaseExceptionGroup
so you can always call BaseExceptionGroup(...) and get the tightest correct
class.
💡A TaskGroup runs five coroutines. Two raise ValueError, one is cancelled. What class does the group have, and does your except Exception at the request boundary catch it?
click to reveal
The group contains a CancelledError, which is a BaseException, so the group is a BaseExceptionGroup — and except Exception does not catch it. Your boundary handler is bypassed and the cancellation propagates, which is exactly right: a request being cancelled is not a request failing, and it must not be converted into a 500.
The subtlety worth internalising is that the presence of one member changes the class of the whole group, and therefore changes which of your handlers runs. A group is not a bag of independent failures; it is one exception whose type is determined by its worst member.
If you want to handle the ValueErrors and let the cancellation through, split() is the tool — take the Exception half, handle it, and re-raise the rest.
except*: the syntax
try:
await do_everything()
except* TimeoutError as eg:
log.warning("%d timed out", len(eg.exceptions))
except* ValueError as eg:
report_bad_input(eg)
Each clause extracts the subgroup of members matching its class, and
crucially more than one clause can run for a single group. That is
completely unlike ordinary except, where the first match wins and the rest are
skipped. Anything matched by no clause propagates automatically, still wrapped
in a group.
The type of the bound name is the thing everyone gets wrong on first contact:
except* ValueError as eg:
reveal_type(eg) # ExceptionGroup[ValueError], NOT ValueError
eg is a group of ValueError, even if there is exactly one. So eg.args,
str(eg) and eg.__cause__ are the group’s, not the member’s — to reach the
actual failures you iterate eg.exceptions, and recurse, because members can
themselves be groups.
The syntax rules are strict and all follow from “a clause may match a subgroup rather than a single object”:
-
You may not mix
exceptandexcept*on the sametry. -
You may not write
except* ExceptionGroup(or any group class) — matching a group against a group is ill-defined. -
There is no bare
except*:. -
continue,breakandreturninside anexcept*clause are syntax errors. Assign to a variable and do the control flow after the statement.
💡Why is return a syntax error inside except* when it is perfectly legal inside except?
click to reveal
Because an except* statement can run several of its clauses for one exception, and a return in the first one would silently discard the others.
Consider a group holding a TimeoutError and a ValueError with clauses for both. If the timeout clause returned, the ValueError clause would never run — and, worse, the unmatched remainder that should have propagated would be swallowed by a normal function return. The result would depend on clause order, which is exactly the kind of non-obvious control flow the feature exists to eliminate.
Rather than define a rule for it (run remaining clauses first? re-raise afterwards? merge?), the language forbids the construct. The workaround is mechanical and, honestly, clearer: bind results to variables declared before the try, and return after the statement completes.
split(): the programmatic API
except* is the ergonomic front door. Underneath it is a small API you should
reach for whenever the classification is data rather than syntax.
matching, rest = eg.split(TimeoutError)
Both halves are trees of the same shape as the original, with non-matching
branches pruned and empty branches removed entirely. Either half may be None,
meaning “nothing on this side” — None, not an empty group, because
ExceptionGroup refuses to be constructed with zero members.
condition can be a class, a tuple of classes, or a predicate function, which
makes split far more expressive than except*:
retryable, terminal = eg.split(lambda e: getattr(e, "retryable", False))
eg.subgroup(cond) is split(cond)[0]. eg.derive(excs) builds a new group of
the same class with new members, and is the hook to override in a custom group
subclass so split returns your class rather than the base one.
Two properties that make this usable in anger: message is copied onto every
derived group, and so is __notes__. Context you attached with add_note()
deep inside a worker survives being split out and re-raised at the top of a
TaskGroup.
The idiom worth memorising
Whenever you classify a group, split off the non-Exception part first and
re-raise it:
recoverable, fatal = eg.split(Exception)
if fatal is not None:
raise fatal
Then classify recoverable however you like. Skip that step and the day a
cancellation happens to travel next to a retryable error, your retry loop
retries the cancellation. That bug is very hard to find and very easy to
prevent.
Where it shows up whether you asked for it or not
asyncio.TaskGroup (3.11) raises an ExceptionGroup when its children fail —
that is the single most common way a group will arrive in your code. So the
migration from asyncio.gather(..., return_exceptions=True) to TaskGroup is
also a migration in how you handle errors, and code that catches a bare
TimeoutError around a TaskGroup will simply stop catching anything.
BaseExceptionGroup is also what contextlib.ExitStack-style multi-cleanup and
several third-party concurrency libraries have standardised on. It is worth
being fluent before you meet it in an incident.
Summary
- A group is one exception whose class is decided by its worst member.
-
except*extracts subgroups, can run multiple clauses, and bindsExceptionGroup[T]— neverT. -
No mixing with
except, no matching a group class, noreturn/break/continueinside a clause. -
split()is the programmatic form and takes predicates as well as classes. -
Always split off the non-
Exceptionhalf first, and re-raise it.