Python's Exception Model in Depth — chaining, hierarchy, and the group
Three questions decide whether a raised error tells the truth or lies.
ExceptionGroup / except* (PEP 654, Python 3.11) returned zero tome hits. The vault's canonical Python text (Lutz, Learning Python) predates the feature. §III is grounded in the primary CPython docs + PEP 654, not a tome. Acquisition target — Ramalho, Fluent Python 2nd ed (2022), already flagged in the sprint tome anchor.Chaining answers whether the cause survives the translation. A hierarchy answers whether callers can catch by category. The group answers whether a concurrent step reports every failure or only the first.
§ IFrame
The Ops lesson this morning made a decision for every failed step: retry, degrade, or fail loud. That lesson lived at the level of policy. This one lives one floor down, in the exception model itself, where three questions decide whether a raised error tells the truth or lies. When a low-level failure becomes a high-level one, does the traceback still show the original cause, or has it been erased? When a caller wants to handle a whole family of your errors at once, can it, or must it enumerate every leaf type? And when a step ran ten operations concurrently and four of them failed, does the caller see all four, or only whichever raised first?
Python answers all three, and the answers are the difference between an exception that a debugger can follow at 3 a.m. and one that dead-ends in a stack trace pointing at the wrong line. Chaining answers the first. A custom hierarchy answers the second. ExceptionGroup and except* answer the third. Take them in that order, because each builds on the one before.
§ IILanguage Idiom — Chaining and the Hierarchy
Chaining with raise from. When you catch a low-level exception and raise a high-level one in its place, you are translating: a ConnectionError from the socket layer becomes a WarehouseUnavailable your callers understand. Done carelessly, the translation destroys evidence. The caller sees WarehouseUnavailable and has no idea a socket timeout caused it. Python preserves the evidence automatically through implicit chaining — an exception raised inside an except block carries a __context__ reference to the one being handled — and lets you make the link explicit and intentional with raise NewError() from original. The explicit form sets __cause__, and the traceback prints The above exception was the direct cause of the following exception, stitching both stacks together so the reader sees the socket timeout and the warehouse translation in one view.
The one time you want the opposite is when the original is noise. raise CleanError() from None suppresses the context so the traceback shows only your deliberate exception. Reach for it rarely; a suppressed cause is evidence you chose to burn, and you should be sure it was noise before you burn it.
The custom hierarchy. A library that raises bare ValueError and RuntimeError forces every caller to catch Python's built-in types, which collide with errors from every other library in the process. Define one root exception for your package and derive the specific ones from it. Now a caller writes except WarehouseError to catch anything your warehouse layer can raise, or except WarehouseWriteError to catch only the write failures, and the inheritance tree gives them the choice of altitude. Lutz makes the structural point in the built-in-exceptions chapter that grounds this lesson: exceptions are matched by class, and a handler for a superclass catches every subclass, so the shape of your exception tree is the API your callers program their error handling against. Design it as deliberately as you design your function signatures.
§ IIIThe Concurrent Case — ExceptionGroup and except*
The hardest case is the one the first two do not cover. A step that fetches ten URLs concurrently, or runs a TaskGroup of ten workers, can have more than one of them fail. The classic model has no way to express this: an except block catches one exception, and the moment it does, the other nine failures are lost. You learn about the first broken URL and stay ignorant of the other three until you fix the first and re-run.
Python 3.11 added ExceptionGroup for exactly this, standardized in PEP 654. An ExceptionGroup is a single exception that wraps many, and asyncio.TaskGroup raises one automatically when several of its tasks fail — every failure is inside the group, none is discarded. To handle a group you use the new except* syntax, which matches by type across the members of the group and can run more than once for a single group. Written except* ConnectionError, the block receives a sub-group containing only the connection errors; a following except* ValueError receives a sub-group of the value errors; both run, each seeing its own members. The failures are partitioned by type and handled in parallel, which is the concurrent analogue of the ordinary handler chain.
This is the machinery under the Ops lesson's degrade response when the step is concurrent. A single sequential enrichment that fails, degrades, and moves on is straightforward. Ten concurrent enrichments where four fail need except* so the caller can degrade all four — record four skips, not one — and re-raise anything that is not degradable. Without the group, three of those four failures are invisible, and invisible failures are the ones that page you a week later. The one caution the vault's canon does not yet cover, because the standard Python text here predates the feature, is that except* and plain except cannot be mixed in the same try: a try statement is either group-aware or it is not. Choose per statement based on whether the code inside can raise a group.
§ IVCode Worked Example — A Chained Hierarchy Under a TaskGroup
Bring the three together. A warehouse client defines a small hierarchy, translates low-level failures into it with raise from so the cause survives, and a concurrent loader runs many writes under a TaskGroup and handles the resulting ExceptionGroup with except*.
The hierarchy is four lines and it is the client's error API.
class WarehouseError(Exception):
pass
class WarehouseUnavailable(WarehouseError):
pass
class WarehouseWriteError(WarehouseError):
def __init__(self, table, cause_detail):
super().__init__(f"write to {table} failed: {cause_detail}")
self.table = table
Every warehouse failure is a WarehouseError, so a caller can catch the whole family with one handler; a caller who cares specifically about write failures catches WarehouseWriteError and reads its table attribute. The translation layer catches the transport exception and re-raises into the hierarchy, chaining so the original cause stays attached.
def write_partition(client, table, rows):
try:
client.raw_write(table, rows)
except ConnectionError as err:
raise WarehouseUnavailable(f"{table} unreachable") from err
except ValueError as err:
raise WarehouseWriteError(table, str(err)) from err
A caller who sees WarehouseUnavailable and prints its traceback sees the ConnectionError underneath it, because from err set __cause__. The translation gained the caller a clean type without costing the debugger the real cause. The concurrent loader runs one write_partition per table under a TaskGroup, and if several tables fail, the group carries all of them.
async def load_all(client, partitions):
try:
async with asyncio.TaskGroup() as tg:
for table, rows in partitions.items():
tg.create_task(asyncio.to_thread(write_partition, client, table, rows))
except* WarehouseUnavailable as group:
for err in group.exceptions:
log.error("partition unreachable; will retry next cycle", exc_info=err)
except* WarehouseWriteError as group:
for err in group.exceptions:
log.error("partition rejected; quarantining", extra={"table": err.table})
Read the handler. The TaskGroup runs every partition write concurrently and, if any fail, raises one ExceptionGroup holding them all. The first except* receives only the unavailable-partition failures and treats them as retry-next-cycle; the second receives only the write rejections and quarantines each by table. Both handlers run for one group, each seeing exactly its own members, so a night where three partitions are unreachable and two are malformed produces five correctly-routed log lines instead of one exception that hid the other four. The Ops decision — retry the transient, quarantine the rejected — is now executable across a concurrent batch, which the sequential exception model could not express.
§ VConnection to Today's Ops Lesson
The Ops lesson named three responses; this lesson supplies what makes each honest at the language level. Fail-loud only tells the truth if the exception it raises still carries the original cause, which is chaining. Degrade only scales past a single sequential step if a concurrent batch can report every failure it degraded, which is ExceptionGroup. And a caller can only give a whole class of failures one response — retry all the unavailables, quarantine all the rejects — if the exception hierarchy lets it catch by category instead of by leaf. Policy up top, mechanism down here; the two lessons are one system seen from two floors.
§ VIPrior-Lesson Reach
This extends the context-manager lesson from earlier this month directly. That lesson's __exit__ ran cleanup whether the block left normally or by exception, and returned a truthy value only when it deliberately suppressed one — the same power raise from None gives, aimed the same way, at deciding what the caller downstream is allowed to see. It also completes the asyncio-semaphore lesson: that lesson bounded how many operations run concurrently, and this one handles what happens when several of those bounded operations fail at once, which the earlier lesson ran but did not yet catch as a group. The enum state-machine lesson is the near neighbor in spirit — both are about making illegal states unrepresentable, one through a transition table, this one through an exception tree the caller cannot catch too broadly by accident.
§ VIIClosing
An exception is only as useful as the truth it carries. Chain it so the translation keeps the cause. Build the hierarchy so the caller chooses the altitude of its handler. Reach for the group the moment a step runs concurrently, so no failure hides behind whichever one raised first.
Take a library you maintain and read its exceptions. Does every layer raise into one root type, or does it leak bare ValueError to its callers? Give it a root, chain its translations, and the next person to debug it will see the whole story in one traceback.
Paired lessons → Ops 01-Earth-DevOps/Synthesis-Lessons/2026-07-24-resilient-python-automation-... · Cert Cert-Prep/Python-Institute/2026-07-24-pcap-exceptions-and-error-handling-...
Filed 2026-07-24 Friday Fajr · Dev lesson · pure Python-language depth · Python deep-mastery track (day 2)
Backward-Synergy-Reach → context-manager (07-14) + asyncio semaphore (06-16) + enum state-machine (06-30)
Grounded in Learning Python (Lutz) Ch 33–34 · ExceptionGroup knowledge-gap logged · HTML shipped in-cycle · earth-accent