How do ExceptionGroup.split and subgroup let you recover from part of a group and re-raise the rest?
answer
- Handle one half, re-raise the other
- Partitioning without a try statement
- The predicate need not be a type
- Either half may come back as None
- Nesting and leaf tracebacks survive
basics
~20 ssubgroup(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.
solid answer
~50 sBoth methods partition a group without a try statement. `subgroup(condition)` returns a group of the matching members or `None`; `split(condition)` returns the pair `(matching, non_matching)`, either side possibly `None`. The condition is an exception type, a tuple of types, or a callable taking one exception and returning a bool — which is how you match on state a type cannot express. Both recurse into nested groups and rebuild the surviving structure through `derive`, copying each leaf's traceback, cause and notes, so the halves stay as debuggable as the original. In a batch extractor that reports its failures as one group, you take the half you can degrade around — the clock-skew artefacts — record it, and `raise` the other half unchanged. `except*` is syntax over the same split; call the methods directly when the predicate is not a type, when the handling lives in a helper, or when you need both halves at once.
code
python · 29 linesclass ClockSkewError(ValueError):
pass
def extract_all(paths):
failures = []
for path in paths:
try:
if path.endswith('.mkv'):
raise ClockSkewError(path + ': timestamp ahead of wall clock')
raise OSError(path + ': unreadable header')
except Exception as exc:
failures.append(exc)
if failures:
raise ExceptionGroup('metadata extraction failed', failures)
def extract_batch(paths):
try:
extract_all(paths)
except ExceptionGroup as eg:
skew, fatal = eg.split(ClockSkewError)
if skew is not None:
print('degraded:', [str(exc) for exc in skew.exceptions])
if fatal is not None:
raise fatal
try:
extract_batch(['a.mkv', 'b.mp4'])
except ExceptionGroup as fatal:
print('propagated:', [str(exc) for exc in fatal.exceptions])go deeper
Know the two names and their shapes: subgroup gives you the matching part or None, split gives you both parts as a pair. They partition a group without needing a try statement around the code.
Explain that the condition may be a callable, that both methods recurse into nested groups and rebuild the structure through derive, and that either half of a split can be None. Show a handle-one-half, re-raise-the-other snippet.
Demonstrate the production judgement: partial recovery under a latency budget, re-raising the fatal half unchanged so callers see the real tracebacks, and refusing to flatten a group into log strings. Catch BaseExceptionGroup in supervision paths.
Own the failure policy for fan-out work: which failure classes are degradable, where that decision is encoded so it is not duplicated per call site, and what a partially-successful batch reports to its caller and to your metrics.
### Two methods, one operation Every exception group carries a pair of partitioning methods: * `subgroup(condition)` returns a new group containing only the members that satisfy the condition, or `None` when nothing matches. * `split(condition)` returns a two-tuple `(matching, non_matching)`. Either element may be `None` — a group where everything matches gives `(group, None)`, and one where nothing matches gives `(None, group)`. The condition is an exception type, a tuple of types, or a callable that takes one exception and returns a bool. The callable form is the reason to use these methods at all rather than `except*`: it can ask questions a type cannot, such as whether an `OSError` has a particular `errno`, whether a timestamp delta is inside tolerance, or whether the failure names a resource you are willing to skip. Both methods recurse. For a nested group they descend into each child, keep the branches that still contain matching leaves, and drop the branches that do not — so the surviving structure mirrors the original rather than being flattened. The rebuilding goes through `derive`, which is also what a group subclass overrides to carry extra state through a split; each leaf keeps its own `__traceback__`, `__cause__`, `__context__` and `__notes__`, and the group's own traceback is copied onto both halves. Neither method mutates the original group. ### Partial recovery, concretely Take a video-metadata extractor that fans out across a batch of files and reports its failures as one group. Two failure modes are mixed inside it. Some files fail with a clock-skew artefact — container timestamps ahead of the wall clock — which is recoverable: you can clamp the timestamps, mark the record degraded, and still return metadata. The rest are real: an unreadable header, a truncated file, a codec you cannot parse. The 92nd-percentile budget for the batch does not survive a retry of everything, so "fail the whole batch" is the wrong answer and "log everything and continue" is worse. ```python try: extract_all(paths) except ExceptionGroup as eg: skew, fatal = eg.split(ClockSkewError) if skew is not None: record_degraded(skew) if fatal is not None: raise fatal ``` That is the shape to be able to write on a whiteboard. The recoverable half is handled and accounted for; the other half propagates as a real exception group, with its structure and tracebacks intact, so the caller sees exactly what it would have seen if nothing had been handled. Nothing is swallowed, and nothing is re-wrapped into a shape the caller has to re-parse. When the predicate is not a type — say only skew beyond tolerance is fatal — the callable form does the same job: ```python recoverable, fatal = eg.split(lambda exc: isinstance(exc, ClockSkewError) and exc.delta_seconds < 5) ``` ### When to use these over `except*` `except*` is the ergonomic form and should be the default at a try statement. Reach for the methods when: * **the condition is not a type** — `except*` can only name types; * **you need both halves at once** — `except*` hands you the matching part and re-raises the rest implicitly, which is right for handling but wrong when you want to record one half and return the other; * **the handling lives away from the try statement** — a supervisor helper, a retry policy, or a decorator that takes a group and decides what is retryable is a plain function that takes a group and calls `split`; * **you are writing a library** whose caller will do the catching, and you only want to reshape what propagates. ### Observability, and the mistake to avoid The common failure here is flattening. It is tempting to catch the group, walk it into a list of strings, log those, and re-raise something generic. That throws away every leaf traceback and the nesting that says which stage produced what — the two things that make a fan-out failure diagnosable. Prefer to keep the group and let the standard rendering print its tree; `traceback.print_exception` on a group prints each leaf under numbered markers. If you need a flat view for a metric, walk the leaves explicitly and count them by type, but keep the group object itself for the log and for the re-raise: ```python def leaves(exc): if isinstance(exc, BaseExceptionGroup): for sub in exc.exceptions: yield from leaves(sub) else: yield exc ``` Two more details worth stating. Re-raising the non-matching half is `raise fatal` — a fresh raise of an existing exception object, which records the handled group as its context; that is usually what you want in a log. And in cleanup or supervision code, catch `BaseExceptionGroup` rather than `ExceptionGroup`, and check membership with `isinstance`, so a group carrying a control-flow signal is not silently missed. ### Version note `split`, `subgroup` and `derive` arrived with the group types in Python 3.11 (PEP 654) and are unchanged through 3.14. A related 3.12 change: `contextlib.suppress` performs this same split internally, removing the matching members from a group and re-raising the remainder rather than treating the group as unmatched.
- What can the condition argument to subgroup or split be?An exception type, a tuple of types, or a callable taking one exception and returning a bool. The callable form is the reason to use these methods over `except*`, which can only name types: it lets you match on an errno, a numeric tolerance, or any attribute of the failure. Both forms recurse into nested groups and keep only the branches that still hold a matching leaf.
- Why keep the group structure instead of flattening the failures into a list of strings?The nesting records which stage or shard produced which failure, and each leaf keeps its own traceback, cause and notes; the standard rendering prints that as a numbered tree. Flatten to strings and you lose exactly the information that makes a fan-out failure diagnosable. Walk the leaves for a metric if you need one, but log and re-raise the group itself.
- When would you use split rather than an except* clause?When the predicate is not a type, when you need both halves in hand at once — record one, re-raise the other — or when the decision lives in a helper away from any try statement, such as a retry policy that is handed a group and must say what is retryable. `except*` is syntax over the same partitioning and is the better default at the try itself.
- Does split mutate the original group or reuse its members?It does not mutate anything. Both halves are new group objects built through `derive`, holding the same leaf exception instances, each still carrying its own traceback, cause, context and notes; the group's traceback is copied onto the halves. A group subclass that carries extra state overrides `derive` so the halves keep it.
saying these in an interview costs you the question
- Flattens the group to strings and loses every traceback
- Assumes split always returns two groups, never None
- Swallows the non-matching half instead of re-raising it
- Thinks subgroup mutates or empties the original group
- Believes the condition must be an exception type
- Re-wraps the remainder in a fresh generic exception