Skip to content
← All articles

ExceptionGroup and except*

Concurrency changed the shape of failure: when ten tasks fail differently, `except` makes you lose nine. How groups model a failure as a tree, why `BaseExceptionGroup` is deliberately not an `Exception`, and why `except* ValueError` binds an `ExceptionGroup[ValueError]`.

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()])
  • ExceptionGroup may contain only Exception members, and is itself an Exception. So except Exception catches it, which is what you want: a boundary handler that already catches Exception keeps working when the code underneath starts raising groups.
  • BaseExceptionGroup may contain anything. When it holds a member that is not an Exception, it is deliberately not an Exception — so except Exception will 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 except and except* on the same try.
  • 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, break and return inside an except* 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 binds ExceptionGroup[T] — never T.
  • No mixing with except, no matching a group class, no return/break/ continue inside a clause.
  • split() is the programmatic form and takes predicates as well as classes.
  • Always split off the non-Exception half first, and re-raise it.