Topic 7.3
Exception Groups and except*
In one line
An ExceptionGroup bundles several unrelated errors into one exception, and except* handles the members of a group by type. They exist because concurrent code (like asyncio.TaskGroup) can fail in many places at once.
Think of it like this
A school bus with a flat tyre, an empty fuel tank and a broken door. A report that says only "the bus has a problem" hides two of the three. A good report lists all three problems together in one note, and then each repair person takes the problem they know how to fix. An exception group is that single note: one exception object that carries a list of other exceptions.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- ExceptionGroup
- An exception that holds a list of other exceptions. Added in Python 3.11.
- except*
- A handler that picks out the members of a group by type and handles just those.
- Sub-exception
- One of the exceptions inside a group. They are listed in the .exceptions attribute.
- split / subgroup
- Methods that divide a group by type, or extract the part that matches, without raising anything.
- TaskGroup
- An asyncio tool that runs tasks together and raises an ExceptionGroup if some of them fail.
- Concurrent
- Several jobs in progress during the same period of time, so more than one can fail.
Step by step
01Build and read a group
The first argument is a message for the whole group. The second is a list of exceptions. Printing the group shows the message and the count. The members are available as a tuple (a fixed list, Topic 4.3) in .exceptions.
eg = ExceptionGroup("batch failed", [
ValueError("bad price"),
TypeError("name must be text"),
ValueError("bad quantity"),
])
print(eg)
print(len(eg.exceptions))02Handle by type with except*
This continues from the group eg built in the previous step. Each except* takes the members of its type. In the code below the handler for ValueError receives a group with two members, and the handler for TypeError receives a group with one. Both run, one after the other, because both found members. Nothing is left, so no error escapes. ([str(e) for e in group.exceptions] is a list comprehension, Topic 4.7: it turns each error into its message text.)
try:
raise eg
except* ValueError as group:
print("value problems:", [str(e) for e in group.exceptions])
except* TypeError as group:
print("type problems:", [str(e) for e in group.exceptions])03What is not handled is raised again
If a member has no matching except*, it stays in a group that is raised after the handlers finish. The caller then sees an ExceptionGroup with only the leftover members. Nothing is swallowed.
try:
try:
raise ExceptionGroup("batch", [ValueError("a"), KeyError("b")])
except* ValueError:
print("handled the ValueError")
except ExceptionGroup as rest:
print("left over:", [type(e).__name__ for e in rest.exceptions])04Raising one group from a loop
Collect errors in a list while looping, then raise a single group at the end if the list is not empty. This reports every problem in one run instead of one at a time. enumerate(rows, start=1) counts the rows from 1, so the note can say which row failed (Topic 2.6 covers enumerate).
def check_all(rows):
problems = []
for number, row in enumerate(rows, start=1):
try:
int(row)
except ValueError as e:
e.add_note(f"row {number}")
problems.append(e)
if problems:
raise ExceptionGroup("invalid rows", problems)05split and subgroup
eg.split(ValueError) returns a pair: a group of the matching members and a group of the rest (either can be None). eg.subgroup(ValueError) returns only the matching part. They are useful for logging or inspecting a group without using except*.
matching, rest = eg.split(ValueError)
print(len(matching.exceptions), len(rest.exceptions))
print(eg.subgroup(KeyError)) # None: no KeyError inside06Where groups come from: TaskGroup
In asyncio, a TaskGroup starts tasks that run side by side. If one fails, the others are cancelled, and when all have finished the block raises an ExceptionGroup with the failures. You will learn async and await in Topic 12.6; for now, see that the way of catching the failures is exactly except*.
import asyncio
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(job("a", fail=True))
tg.create_task(job("b", fail=False))
except* ValueError as group:
print("failed:", len(group.exceptions))
asyncio.run(main())Try it yourself
- 1
Add a handler
In the second example add
except* KeyError as group:inside the innertry. What does the outerexcept ExceptionGroupprint now? (Nothing, because nothing is left over, so the outer handler does not run.) - 2
Count the survivors
In the split example, change
eg.split(ValueError)toeg.split((ValueError, TypeError)). What arematchingandrestnow? Check whetherrestisNone. - 3
Make a task succeed
In the TaskGroup example change the
Truefor jobctoFalseand run it. How many errors appear? Even with one failure you still needexcept*.
Code & diagrams
Both handlers run, because each finds members of its own type.
Expected output
batch failed (3 sub-exceptions)
value problems: ['bad price', 'bad quantity']
type problems: ['name must be text']
after the try statementThe KeyError had no handler, so it comes out in a new group. Add an except* KeyError to handle it too.
Expected output
handled: ["ValueError('a')"]
left over: ["KeyError('b')"]Errors are collected while the loop continues, then raised as one group at the end.
Expected output
invalid rows (3 sub-exceptions)
- invalid literal for int() with base 10: 'x' | row 2
- invalid literal for int() with base 10: '' | row 4
- invalid literal for int() with base 10: '9z' | row 5Expected output
matching: ['a', 'c']
rest: ['b']
subgroup(KeyError): NoneTwo tasks fail at about the same time. The group holds both. asyncio is covered in Phase 12.
Expected output
caught: job a failed
caught: job c failedBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Mix except and except*
Put a plain except and an except* in the same try statement.
try:
pass
except ValueError:
pass
except* TypeError:
passBreak #2
An empty group
Create an ExceptionGroup with no members.
errors = []
raise ExceptionGroup("nothing wrong", errors)Myth vs fact
Myth
except ValueError will catch a ValueError inside an ExceptionGroup.
Fact
It will not. The thing raised is the group, which is not a ValueError. You need except* ValueError (or catch ExceptionGroup and inspect it).
Myth
Exception groups are only for experts and only for async code.
Fact
They are a plain tool: use them whenever you want to report several independent failures together. asyncio.TaskGroup is the best-known producer, not the only one.
Myth
Once one except* matches, the rest are skipped, like ordinary except.
Fact
Every except* that finds matching members runs. Each takes its share of the group.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
except*handlers receive a new group made bysplit. The new group keeps the original message, traceback and__cause__/__context__information, so the chain is not lost. Re-raising inside a handler with a bareraisere-raises only that handler's part. - ▸
If a handler raises a new exception, it is combined with the unhandled rest into another group, so errors in error handling are not lost either.
- ▸
Libraries that fan out work (HTTP clients, scrapers, batch embedders) increasingly expose groups. Write the top-level of such code as
try: ... except* SomeErrorand log each member, so a single bad URL does not hide nine other failures. - ▸
except*also accepts a plain exception that is not inside any group:raise ValueError("solo")caught byexcept* ValueError as eggivesegas anExceptionGroupwith one member. That is why code that must handle both shapes (a library that sometimes raises a bare error, sometimes a group) can useexcept*alone. A bareexcept*:with no class is aSyntaxError, andsplitkeeps nested group structure, so aValueErrorinside an inner group stays inside a smaller inner group. - ▸
splitandsubgroupalso accept a function instead of a class, for exampleeg.subgroup(lambda e: getattr(e, "status", 0) >= 500), which lets you separate retryable failures from permanent ones in a batch of HTTP errors. If you want to build a group of your own subclass that keeps its extra fields throughsplit, overridederive(). - ▸
BaseExceptionGroupcan carryKeyboardInterrupt-style exceptions too.ExceptionGroupcan hold onlyExceptionsubclasses, and Python picks the right class automatically when you callBaseExceptionGroup(...).
Remember this
- 1
Until now one
tryhandled one error at a time, because code ran one step at a time. Concurrent code (many tasks running side by side, Phase 12) can fail in several places at once. Throwing away all but one error would lose information. Python 3.11 addedExceptionGroupfor this:ExceptionGroup("message", [error1, error2, ...]). - 2
The group is itself an exception. Its members are in
eg.exceptions(hereegis just a variable name holding the group). They may be of different types, and may even be other groups. Recall from Topic 4.1:[a, b]is a list, so the second argument is simply a list of exception objects. - 3
To handle a group you use **
except*** (read it as "except star") instead ofexcept.except* ValueError as eg:picks theValueErrormembers out of the group. It runs the handler with a new smaller group (still anExceptionGroup) holding only those. Members of other types stay behind. You can write severalexcept*clauses. Unlike normalexcept, all that match will run, each taking its own share. - 4
Whatever is left after every
except*has taken its members is raised again as a group, so nothing is lost silently. If every member was handled, thetryends normally. This is the key difference fromexcept. A plainexcept ValueErroronly matches if the exception itself is aValueError. AnExceptionGroupis not one, so a plainexceptcannot look inside a group. - 5
You can also write your own code that collects errors in a loop and raises one
ExceptionGroupat the end. This is a good fit for checking a form or a batch of files. Instead of stopping at the first mistake, you report all of them. Two helper methods let you look inside a group without catching it.eg.split(ValueError)returns two groups (matching and rest).eg.subgroup(ValueError)returns only the matching part, orNone(Python's "no value", Topic 1.4). - 6
The most common place you will meet groups is
asyncio.TaskGroup(Topic 12.7). A task is one job that runs side by side with others. If several tasks inside the group fail, theasync withblock raises oneExceptionGroupholding all the failures. Even one failure arrives wrapped in a group, so code around aTaskGroupnormally usesexcept*. - 7
Rules to remember. You cannot mix
exceptandexcept*in the sametry. You cannot usebreak,continueorreturninside anexcept*block.ExceptionGroupneeds a non-empty list of exceptions. There is alsoBaseExceptionGroupfor groups that may containBaseExceptionmembers. In AI code the pattern is natural: fire 50 model calls at once, let the ones that fail raise, and report all of the failures together rather than only the first.
Explain it without notes
What problem does ExceptionGroup solve, and why was it added in Python 3.11?
How does except* differ from except? What happens to members that no handler matches?
Why does async with asyncio.TaskGroup() raise an ExceptionGroup even when only one task failed?
When would you raise your own ExceptionGroup?
Practice
Write check_prices(prices) that tests each value is a number greater than zero and raises one ExceptionGroup listing every bad value (ValueError for the wrong number, TypeError for text).
Catch the group from your first answer using two except* clauses and print how many of each kind there were.
Given eg = ExceptionGroup("x", [ValueError(1), OSError(2)]), print the class names of the members that are not ValueError, without using except*.
Trade-offs
- ↔
One report versus simple handling. Groups give complete information, but code that handles them is more involved than a single
except. Use them when losing errors would hurt, not for every function. - ↔
Collecting errors versus failing fast. Collecting shows all problems in one run (good for validation). Failing fast stops wasted work and is simpler when later steps depend on earlier ones.
Done when you can
Done when you can create an
ExceptionGroupand read its.exceptions.Done when you can handle a group with two
except*clauses and explain what is raised again.Done when you can explain why a plain
except ValueErrordoes not see inside a group.Done when you can use
splitandsubgroupto inspect a group.Done when you can say where groups come from in
asyncio.TaskGroup.