skip to content

questions

4

How does an except* clause match an ExceptionGroup, and what happens to unmatched exceptions?

level: middleimportance: must knowfreq 50%

answer

  1. Not first-match-wins
  2. The clause partitions rather than consumes
  3. Several clauses may run in order
  4. Unmatched members come back afterwards
  5. No break, continue or return inside

basics

~20 s

Each except* clause splits the group by type: the matching members arrive as a group, the rest move on to the next clause. Several clauses can run for one raise, and whatever stays unmatched is re-raised after the try statement.

solid answer

~50 s

`except*` clauses are tried in order, and each one *splits* the group rather than consuming it: the members matching that clause's type are collected into a new group and handed to the handler, the remainder is carried to the next clause. So more than one clause can run for a single raise, each at most once. The object bound by `as` is always a group, even when the try body raised a bare `ValueError` — Python wraps it first. Anything no clause matched is re-raised automatically when the try statement finishes, keeping the original nesting and every leaf's traceback, and if a handler itself raises, that exception is merged into the same propagating group. The syntax is fenced off to make this work: no bare `except*:`, no mixing `except*` with plain `except` on one try, and no `break`, `continue` or `return` in an `except*` body.

code

python · 7 lines
python
try:
    try:
        raise ExceptionGroup('m', [ValueError('a'), OSError('b')])
    except* ValueError as eg:
        print('handled:', eg.exceptions)
except ExceptionGroup as leftover:
    print('propagated:', leftover.exceptions)

go deeper

for a junior

Remember two facts: an except* clause receives a group of the matching members, not one exception, and anything it does not match is raised again once the try statement ends. Nothing is quietly swallowed.

for a middle

Explain the split model out loud: clauses run in source order, each at most once, more than one can fire for a single raise, and a bare exception gets wrapped first. Know the three syntax restrictions and why they exist.

for a senior

Show judgement about placement: use except* at the boundary around a fan-out, not everywhere, and rely on leftover re-raise so a partial handler cannot mask failure modes it does not understand. Be ready to reason about a handler that itself fails.

for a principal

Own the migration story: except* cannot be compiled conditionally, so adopting it pins a minimum interpreter version, and turning a bare exception into a group at an API boundary silently breaks callers' handlers. Decide where that boundary sits.

### Matching is a split, not a catch A plain `except` clause asks one question — is the propagating object an instance of this type? — and if the answer is yes it consumes the whole exception. An `except*` clause asks a different question of a *group*: which of your members are instances of this type? It then partitions the group into the matching part and the rest. The matching part is wrapped in a new group of the same kind and bound by the `as` name; the rest is carried forward to the next `except*` clause. This has three consequences that interviewers probe directly: 1. **More than one clause can run for a single raise.** With members `ValueError` and `OSError`, both `except* ValueError` and `except* OSError` execute, in source order. This is the biggest behavioural difference from `except`, where the first match wins and the rest are skipped. 2. **Each clause runs at most once**, on the whole matching subgroup, not once per matching member. If you want per-member handling, loop over `eg.exceptions` inside the clause. 3. **The bound object is always a group.** Even `except* ValueError as eg` binds an `ExceptionGroup`, never the `ValueError` itself, so `eg.exceptions[0]` is the leaf. That holds even when the try body raised a bare exception: `except*` wraps a non-group exception in a group before matching, which is what lets one handler cover both a single failure and a fan-out. ### Leftovers are re-raised, never dropped Whatever no clause matched does not disappear. When the try statement finishes, Python re-raises a group containing exactly the unmatched members, preserving the original nesting and each leaf's own traceback, cause, context and notes: ```python try: try: raise ExceptionGroup('m', [ValueError('a'), OSError('b')]) except* ValueError: print('handled the value error') except ExceptionGroup as leftover: print(leftover.exceptions) # (OSError('b'),) ``` That is the property that makes `except*` safe in the middle of a stack: a handler that knows about one failure mode cannot accidentally swallow the ones it does not understand. A bare `raise` inside an `except*` block re-raises the subgroup that clause received, putting it back among the leftovers. ### If a handler itself raises An exception raised inside an `except*` body does not replace the leftovers — the two are combined. At the end of the try statement one group propagates, carrying both the unhandled members and whatever the handlers raised, with the original group recorded as the new exception's `__context__`. Rendering that group shows both branches of the story: what failed originally, and what failed while handling it. ### The syntax restrictions, and why they exist Three things are rejected at compile time: * **Mixing forms.** `cannot have both 'except' and 'except*' on the same 'try'`. The two have incompatible matching models, and allowing both would make the order of evaluation meaningless. * **A bare `except*:`.** It must name one or more types — `expected one or more exception types`. "Catch everything" has no coherent meaning for a construct whose job is to partition by type; use plain `except` (or `except* BaseException` deliberately) if that is really what you want. * **`break`, `continue` and `return` inside an `except*` body** — `'break', 'continue' and 'return' cannot appear in an except* block`. Because several clauses may run and the leftovers must still be re-raised afterwards, jumping out of the middle of that sequence would either lose exceptions or leave the statement half-finished. Set a flag and act on it after the try statement instead. One more restriction is enforced at run time rather than compile time: naming a group type in the clause. `except* ExceptionGroup` raises `TypeError: catching ExceptionGroup with except* is not allowed. Use except instead.` Matching a group by its group-ness would make the partitioning circular, so if you really mean "the whole group", use a plain `except`. `else` and `finally` behave exactly as they do on an ordinary try, and `try`/`except*` may be nested freely. ### Practical notes You do not have to switch a whole codebase to `except*`. It is worth using where a call site can genuinely produce several independent failures — the boundary around a fan-out — and pointless where one exception is raised at a time. Where you need the non-matching half in hand rather than re-raised, or where the predicate is not a type at all, use the group's `split` and `subgroup` methods directly; `except*` is syntax over the same partitioning. ### Version note `except*` is Python 3.11 (PEP 654) and is a syntax error before it, so it cannot be feature-flagged at runtime — a module using it fails to compile on 3.10. Its behaviour is unchanged through 3.14. One related 3.12 change worth knowing: `contextlib.suppress` learned to suppress matching members *inside* a group, splitting it and re-raising the rest, rather than only matching a bare exception.

  • Can a single try statement mix except* and plain except clauses?
    No — that is a SyntaxError: cannot have both 'except' and 'except*' on the same 'try'. A bare `except*:` is rejected too, since it must name at least one type, and `break`, `continue` and `return` cannot appear in an `except*` body. The restrictions exist because several clauses may run and the leftovers still have to be re-raised afterwards. `else` and `finally` work normally.
  • What happens if code inside an except* handler raises a new exception?
    The new exception does not replace the unhandled members; both propagate. When the try statement finishes, one group is raised containing the leftovers and whatever the handlers raised, with the original group set as the new exception's `__context__`. The rendered traceback shows both branches, which is exactly why an `except*` body is not allowed to return or break out of the statement.
  • How do you re-raise only the members a clause matched?
    A bare `raise` inside the `except*` block re-raises the subgroup that clause was handed. It then joins the members no clause matched, and the combined group propagates when the try statement ends — so it behaves like deciding, after inspection, that this failure mode was not yours to handle after all.
  • Does except* work when the try body raises a plain exception rather than a group?
    Yes. Python wraps a non-group exception in a group before matching, so `except* ValueError` catches a bare `ValueError` — but the name bound by `as` is still an `ExceptionGroup`, with the original exception as its single member. That is the point: one handler covers both the single-failure and the fan-out case without branching on which happened.

saying these in an interview costs you the question

  • Thinks only the first matching except* clause runs
  • Expects unmatched members to be silently dropped
  • Believes except* binds a single exception, not a group
  • Mixes except and except* on the same try statement
  • Uses return or break inside an except* body
  • Says except* fails when a bare exception is raised

context

open as a page

What is Python's ExceptionGroup, and what raises one in the standard library?

level: juniorimportance: should knowfreq 38%

basics

~10 s

ExceptionGroup is a built-in exception added in Python 3.11 that carries several exceptions at once in its exceptions tuple. asyncio.TaskGroup raises one when its child tasks fail, so no failure is silently discarded.

open as a page

How do ExceptionGroup and BaseExceptionGroup differ, and when do you need the base one?

level: middleimportance: should knowfreq 28%

basics

~10 s

BaseExceptionGroup derives from BaseException and can hold any exception, including KeyboardInterrupt, SystemExit and asyncio.CancelledError. ExceptionGroup derives from Exception and rejects those with a TypeError. Constructing BaseExceptionGroup with only ordinary exceptions returns an ExceptionGroup.

open as a page

How do ExceptionGroup.split and subgroup let you recover from part of a group and re-raise the rest?

level: seniorimportance: should knowfreq 30%

basics

~20 s

subgroup(condition) returns a new group holding only the members that match, or None if none do. split(condition) returns both halves as a (matching, non_matching) pair, either of which may be None. Nesting, tracebacks and notes are preserved.

open as a page