Do notes added with add_note survive when an except* handler splits an ExceptionGroup?
answer
- Filtering does not rebuild the leaves
- Same member objects, new group nodes
- Group-level notes are copied, not shared
- Annotate each member before collecting
- Notes render on their own tree node
basics
~20 sYes. An except* handler receives a derived group built around the same member exception objects, so notes added to members are still there, and the group's own notes list is copied onto each derived group. Nothing is lost by filtering.
solid answer
~50 s`except*` filters by calling the group's split machinery, which builds **new group objects around the same leaf exceptions**. So a note you added to a member before collecting it into the group is still on that member — it is literally the same object. The group's own `__notes__`, if it has any, is copied onto both the matched and the unmatched group: equal contents, a fresh list, so a note added to one part afterwards does not appear on the other or on the original. Rendering follows the tree: the group's notes print under its `ExceptionGroup: ... (N sub-exceptions)` header, and each member's notes print under that member's own error line inside its branch. The practical pattern is to annotate each leaf exception with its identifier as you collect it, and put shard-level context on the group.
code
python · 16 linesfailures = []
for record_id, raw in ((11, b"\xff"), (12, b"\xfe")):
try:
raw.decode("utf-8")
except UnicodeDecodeError as exc:
exc.add_note(f"record_id={record_id}")
failures.append(exc)
group = ExceptionGroup("decode failures", failures)
group.add_note("shard=7")
matched, rest = group.split(UnicodeDecodeError)
print(matched.__notes__) # ['shard=7']
print(matched.__notes__ is group.__notes__) # False - it is a copy
print([m.__notes__ for m in matched.exceptions])
print(rest) # None - nothing else was in therego deeper
Know that an exception group can hold many failures and that each one can carry its own note, so a batch failure can still say which item broke. The filtering details come later.
Explain that except* builds a new group around the same member objects, so member notes are intact, and that the group's own notes are copied onto each derived part rather than shared.
Design the annotation for a real fan-out: annotate each leaf with its identifier as you collect it, put batch context on the group, keep notes small when the working set is large, and never route on note text.
Decide the failure-reporting contract across services: what a group's message and notes must always carry, which context is a structured attribute your tooling indexes, and how partial handling with except* keeps the rest reportable.
Exception groups are how a concurrent fan-out reports many failures at once, and notes are how each of those failures says *which unit of work* it came from. The two features shipped one release apart — notes in 3.11 with PEP 678, groups in 3.11 with PEP 654 — and they compose better than most people expect, because filtering a group does not rebuild its leaves. ## The scenario A fraud-scoring service re-scores a 2.4 GB working set of stored records. The records are decoded and scored in parallel; a few carry an encoding mismatch and raise `UnicodeDecodeError`. The failures are collected and reported together: ```python failures = [] for record_id, raw in batch: try: score(raw.decode("utf-8")) except UnicodeDecodeError as exc: exc.add_note(f"record_id={record_id}") failures.append(exc) if failures: group = ExceptionGroup("decode failures", failures) group.add_note("shard=7") raise group ``` Without the per-member note, the caller sees a dozen identical `'utf-8' codec can't decode byte 0xff in position 0` lines and no way to tell which records to quarantine. With it, every branch of the rendered tree names its record. ## What except* actually does to the objects An `except* UnicodeDecodeError` clause does not hand you the original group. It calls the group's splitting machinery to produce the matching part, and the same machinery backs `BaseExceptionGroup.split` and `BaseExceptionGroup.subgroup` when you call them yourself. That machinery: * **reuses the leaf exceptions.** The members of the derived group are the identical objects that were in the original — same identity, same `__traceback__`, same `__notes__`. Nothing about a member's annotation is at risk. * **rebuilds the group nodes**, including nested subgroups, so the derived tree mirrors only the matching part of the original shape. * **copies `__notes__` onto each derived group node.** Both the matched and the unmatched group come out carrying the same note strings in a *new* list. Equal contents, different list object — so appending a note to one part after the split does not touch the other part or the original. That copy is the detail worth knowing. It means group-level context — shard, batch, run identifier — is not lost when a handler peels off the errors it can deal with and re-raises the rest, and it means the two halves cannot accidentally share a mutable list. ## Reading notes programmatically If a predicate needs to inspect notes, remember the attribute is absent until the first `add_note` call: ```python part = group.subgroup(lambda e: any(n.startswith("record_id=") for n in getattr(e, "__notes__", []))) ``` `subgroup` returns `None` when nothing matches, so there is no group to carry notes at all in that case. And filtering on note *text* is a smell: notes are free-form strings for humans. If code must select members by identity, put the identifier on the exception as an attribute and let the note carry the human-readable version of the same fact. ## How it renders Group tracebacks are a tree, and notes attach to whichever node owns them: ``` | ExceptionGroup: decode failures (3 sub-exceptions) | shard=7 +-+---------------- 1 ---------------- | UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte | record index 0 +---------------- 2 ---------------- ``` The group's own notes sit directly under its header line; each member's notes sit under that member's final error line, inside the member's indented branch. Nothing is merged or hoisted, so a reader can see at a glance which context is per-batch and which is per-record. ## Operational payoff and limits The payoff is that one raised object carries the whole failure report: how many failed, of what kind, and which inputs. A handler upstream can `except*` the class it knows how to retry, keep the annotated members for a quarantine queue, and re-raise the rest with their group note intact. The limits are the same as for notes generally. They are strings, so they cost memory for as long as the exception lives — with a large working set, note the record identifier, never the record. They are rendered verbatim wherever the traceback goes, so no secrets. And they are not a routing mechanism: use the exception type and attributes for control flow, and let the notes explain the failure to whoever reads it at three in the morning.
- Are the derived group's notes the same list object as the original group's?No. The contents are equal but the list is a fresh copy, so `matched.__notes__ == group.__notes__` while `matched.__notes__ is group.__notes__` is False. Appending a note to the matched part after the split leaves the original and the unmatched part untouched, which is what you want when a handler annotates only the errors it is taking responsibility for.
- Where exactly do notes appear in a rendered group traceback?On their own node. The group's notes print directly below its `ExceptionGroup: message (N sub-exceptions)` header line; each member's notes print below that member's final error line, inside the member's indented branch of the tree. They are never merged into the header or collected at the end.
- How should you pick members out of a group by their context — by note text?Prefer an attribute. Notes are free-form strings for humans, and a predicate that parses them is a schema-less contract that breaks the first time someone rewords a note. Set the identifier on the exception instance, filter on that, and let the note carry the readable rendering of the same fact.
saying these in an interview costs you the question
- Assumes except* rebuilds fresh member exception objects
- Thinks group-level notes vanish when the group is split
- Reads __notes__ with no default and hits AttributeError
- Parses note text to decide which members to retry
- Believes member notes are hidden inside a group traceback
- Puts whole records rather than record ids in notes