
Exception Groups and except* in Python
For most of Python's history, an exception meant exactly one error. That model breaks down in a few situations that are increasingly common: three concurrent network requests fail at once, a form has five invalid fields, a batch import hits bad data on twenty rows. Raising only the first error throws away information, and the usual workarounds (a list of errors stuffed into a custom exception, or logging and moving on) don't work with try/except.
Python 3.11 added a proper answer: ExceptionGroup, an exception that contains other exceptions, and the except* syntax for handling the parts of a group you care about. They were introduced in PEP 654, and they're the reason asyncio.TaskGroup can report every failing task instead of just one.
In this post I'll cover creating and raising exception groups, how except* matches and splits them, how they show up with asyncio.TaskGroup, the split() and subgroup() methods for working with groups by hand, and the rules and gotchas that trip people up. If you need a refresher on regular exception handling first, see Exception Handling in Python: try, except, else, and finally.
What an ExceptionGroup Is
An ExceptionGroup is a normal exception (it inherits from Exception) that wraps a message and a sequence of other exceptions:
eg = ExceptionGroup(
"validation failed",
[ValueError("age must be positive"), TypeError("name must be a string")],
)
print(eg)
print(eg.message)
print(eg.exceptions)
print(isinstance(eg, Exception))
Output:
validation failed (2 sub-exceptions)
validation failed
(ValueError('age must be positive'), TypeError('name must be a string'))
True
Two attributes matter:
message: the description you passed in.exceptions: a tuple of the contained exceptions. These can themselves be exception groups, so groups can nest into a tree.
The constructor has some rules. The list must be non-empty (an empty one raises ValueError), and ExceptionGroup can only contain Exception subclasses. If you need to group something like KeyboardInterrupt, use BaseExceptionGroup, the parent class. In practice, you'll almost always use ExceptionGroup. Calling BaseExceptionGroup(...) with only regular exceptions actually hands you an ExceptionGroup back.
What the Traceback Looks Like
When an exception group goes unhandled, Python prints a tree-shaped traceback:
raise ExceptionGroup(
"validation failed",
[ValueError("age must be positive"), TypeError("name must be a string")],
)
+ Exception Group Traceback (most recent call last):
| File "validate.py", line 1, in <module>
| raise ExceptionGroup(
| ...<2 lines>...
| )
| ExceptionGroup: validation failed (2 sub-exceptions)
+-+---------------- 1 ----------------
| ValueError: age must be positive
+---------------- 2 ----------------
| TypeError: name must be a string
+------------------------------------
Each sub-exception gets its own numbered section, and if a sub-exception was raised (rather than just constructed), its own traceback appears inside that section.
Handling Groups with except*
You could catch an exception group with a plain except ExceptionGroup: and loop over .exceptions yourself, and sometimes that's fine. But the real tool is except*, which matches the contents of a group by type:
# validate.py
def validate(user: dict) -> None:
errors: list[Exception] = []
if not isinstance(user.get("name"), str):
errors.append(TypeError("name must be a string"))
if user.get("age", 0) <= 0:
errors.append(ValueError("age must be positive"))
if "@" not in user.get("email", ""):
errors.append(ValueError("email must contain '@'"))
if errors:
raise ExceptionGroup("invalid user", errors)
try:
validate({"name": 42, "age": -1, "email": "nope"})
except* ValueError as eg:
for e in eg.exceptions:
print("value problem:", e)
except* TypeError as eg:
for e in eg.exceptions:
print("type problem:", e)
Output:
value problem: age must be positive
value problem: email must contain '@'
type problem: name must be a string
This is the key difference from regular except: more than one except* clause can run for a single raised group. Python works through the clauses top to bottom. Each clause pulls out the sub-exceptions that match its type, runs its body with those, and passes whatever's left to the next clause.
A few more details:
- The variable bound by
asis always an exception group, never the bare exception. Even if only oneValueErrormatched, you get anExceptionGroupcontaining it, so you loop overeg.exceptions. - Matching looks through nested groups too. A
ValueErrorburied two levels deep still matchesexcept* ValueError, and the nesting structure is preserved in what you receive. - You can match several types at once with a tuple:
except* (ValueError, TypeError) as eg:.
Unhandled Parts Are Re-raised
Anything that no except* clause matched is re-raised automatically, as a group containing only the leftovers:
try:
raise ExceptionGroup("batch", [ValueError("a"), KeyError("b")])
except* ValueError:
print("handled ValueError")
handled ValueError
+ Exception Group Traceback (most recent call last):
| File "batch.py", line 2, in <module>
| raise ExceptionGroup("batch", [ValueError("a"), KeyError("b")])
| ExceptionGroup: batch (1 sub-exception)
+-+---------------- 1 ----------------
| KeyError: 'b'
+------------------------------------
This is a safety feature. You can't accidentally swallow errors you didn't name, which is easy to do when you're looping over .exceptions manually.
except* Also Catches Naked Exceptions
If the try body raises an ordinary exception instead of a group, except* still works. It wraps the exception in a group for you:
try:
raise ValueError("naked")
except* ValueError as eg:
print(repr(eg))
Output:
ExceptionGroup('', (ValueError('naked'),))
That's convenient when a function may raise either a single error or a group, but it means your handler code should always treat eg as a group.
Plain except Doesn't Look Inside Groups
The reverse is not true. A regular except ValueError: will not catch an ExceptionGroup that contains a ValueError, because the group itself isn't a ValueError:
try:
try:
raise ExceptionGroup("batch", [ValueError("a")])
except ValueError:
print("never printed")
except ExceptionGroup:
print("plain except ValueError doesn't match a group")
Output:
plain except ValueError doesn't match a group
This matters when you change a function from raising single exceptions to raising groups: every caller's except clauses stop matching. Treat that as a breaking API change.
Rules and Restrictions for except*
The syntax has a handful of rules, all enforced by the compiler or the interpreter:
| Rule | What happens if you break it |
|---|---|
A try can't mix except and except* | SyntaxError: cannot have both 'except' and 'except*' on the same 'try' |
No break, continue, or return inside an except* block | SyntaxError: 'break', 'continue' and 'return' cannot appear in an except* block |
You can't use except* ExceptionGroup | TypeError: catching ExceptionGroup with except* is not allowed. Use except instead. |
A bare except*: with no type | SyntaxError |
The break/continue/return rule exists because several except* clauses may run for one group, and jumping out of the middle would leave the rest of the group in limbo. If you need to exit a loop after handling, set a flag in the handler and check it after the try.
else and finally work exactly as they do with ordinary try statements.
Exception Groups and asyncio.TaskGroup
The biggest practical reason exception groups exist is structured concurrency. asyncio.TaskGroup (Python 3.11+) runs several tasks and waits for all of them. If one fails, it cancels the rest, then raises an ExceptionGroup containing every failure:
# fetch_all.py
import asyncio
async def fetch(name: str, delay: float, fail: bool = False) -> str:
await asyncio.sleep(delay)
if fail:
raise ConnectionError(f"{name} unreachable")
return f"{name} ok"
async def main() -> None:
try:
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch("users", 0.1))
t2 = tg.create_task(fetch("orders", 0.2, fail=True))
t3 = tg.create_task(fetch("billing", 0.2, fail=True))
except* ConnectionError as eg:
for e in eg.exceptions:
print("network:", e)
print(t1.result())
asyncio.run(main())
Output:
network: orders unreachable
network: billing unreachable
users ok
Both failures are reported, not just whichever happened first. With the older asyncio.gather(), the default behavior was to raise only the first exception and leave you to discover the rest some other way.
Even when only one task fails, TaskGroup still raises a group (with the message unhandled errors in a TaskGroup), so code that uses task groups should handle errors with except*. For the fundamentals of async/await and tasks, see Asyncio in Python: A Beginner's Guide to Asynchronous Programming.
Raising Your Own Groups: Collect, Then Raise
Outside of async code, the most common use is "keep going, collect every problem, report them all at the end". Batch processing is a natural fit:
# import_rows.py
def process_rows(rows: list[str]) -> list[int]:
errors: list[Exception] = []
results: list[int] = []
for i, row in enumerate(rows, start=1):
try:
results.append(int(row))
except ValueError as e:
e.add_note(f"row {i}")
errors.append(e)
if errors:
raise ExceptionGroup(f"{len(errors)} bad rows", errors)
return results
process_rows(["1", "x", "3", "y"])
The traceback (file paths shortened) shows each bad row with its own traceback and note:
+ Exception Group Traceback (most recent call last):
| File "import_rows.py", line 16, in <module>
| process_rows(["1", "x", "3", "y"])
| ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
| File "import_rows.py", line 12, in process_rows
| raise ExceptionGroup(f"{len(errors)} bad rows", errors)
| ExceptionGroup: 2 bad rows (2 sub-exceptions)
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "import_rows.py", line 7, in process_rows
| results.append(int(row))
| ~~~^^^^^
| ValueError: invalid literal for int() with base 10: 'x'
| row 2
+---------------- 2 ----------------
| Traceback (most recent call last):
| File "import_rows.py", line 7, in process_rows
| results.append(int(row))
| ~~~^^^^^
| ValueError: invalid literal for int() with base 10: 'y'
| row 4
+------------------------------------
add_note() (also new in 3.11) pairs well with groups. Each sub-exception carries its own context, so you don't have to cram the row number into the message.
Collecting errors is a judgment call. It's great for validation and imports, where the user wants a full list of problems to fix. It's a poor fit where one failure makes the rest meaningless, such as a sequence of steps that each depend on the previous one.
Working with Groups Directly: split() and subgroup()
Sometimes you have an exception group as a value and want to slice it up without except*, for example in a logging hook or a test. Two methods help.
split() divides a group into the part that matches and the part that doesn't, preserving the nesting:
eg = ExceptionGroup("outer", [
ValueError("v1"),
ExceptionGroup("inner", [KeyError("k1"), ValueError("v2")]),
])
match, rest = eg.split(ValueError)
print(repr(match))
print(repr(rest))
Output:
ExceptionGroup('outer', [ValueError('v1'), ExceptionGroup('inner', [ValueError('v2')])])
ExceptionGroup('outer', [ExceptionGroup('inner', [KeyError('k1')])])
subgroup() returns just the matching part, or None if nothing matches. Both methods accept an exception type, a tuple of types, or a predicate function:
only_keys = eg.subgroup(lambda e: isinstance(e, KeyError))
print(repr(only_keys))
print(eg.subgroup(OSError))
Output:
ExceptionGroup('outer', [ExceptionGroup('inner', [KeyError('k1')])])
None
This is exactly the operation except* performs under the hood. Each clause calls the equivalent of split() on what's left of the group.
Flattening a Group
Because groups nest, it's often handy to get a flat list of the actual errors, the "leaves" of the tree. A small recursive generator does it:
def leaves(exc: BaseException):
if isinstance(exc, BaseExceptionGroup):
for sub in exc.exceptions:
yield from leaves(sub)
else:
yield exc
print([repr(e) for e in leaves(eg)])
Output:
["ValueError('v1')", "KeyError('k1')", "ValueError('v2')"]
Checking against BaseExceptionGroup covers both group classes.
Subclassing ExceptionGroup
You can create your own group types, for example to give a batch error a distinct name. When except* or split() carves up your group, Python builds the new, smaller groups by calling derive(). Override it to keep your subclass type:
class BatchError(ExceptionGroup):
def derive(self, excs):
return BatchError(self.message, excs)
try:
raise BatchError(
"import failed",
[ValueError("row 3"), ValueError("row 9"), KeyError("sku")],
)
except* ValueError as eg:
print(type(eg).__name__, len(eg.exceptions))
except* KeyError as eg:
print(type(eg).__name__, eg.exceptions)
Output:
BatchError 2
BatchError (KeyError('sku'),)
Without the derive() override, the pieces you receive in each handler would be plain ExceptionGroup instances. If your subclass adds extra attributes, copy them over in derive() as well.
Raising Inside an except* Handler
If an except* handler raises a new exception, Python doesn't let it silently replace the unhandled parts of the original group. It combines them:
try:
try:
raise ExceptionGroup("batch", [ValueError("a"), TypeError("b")])
except* ValueError as eg:
raise RuntimeError("could not recover") from eg
except ExceptionGroup as e:
print(repr(e))
Output:
ExceptionGroup('', [RuntimeError('could not recover'), ExceptionGroup('batch', [TypeError('b')])])
The TypeError that no clause handled is still there, next to the new RuntimeError. That guarantees no error is lost, but it does mean outer code may receive a group even if your handler raised a single exception. A bare raise inside an except* block, by contrast, simply re-raises the matched part as part of the original group.
When to Use Exception Groups
Exception groups are a specialized tool. Reach for them when:
- Several independent operations ran and more than one may fail: concurrent tasks, parallel requests, or a batch of independent items.
- The caller benefits from seeing all failures: validation errors a user has to fix, or an import report.
- You're using
asyncio.TaskGroup, since it raises them whether you like it or not.
Stick with regular exceptions when an operation fails at a single point. Most functions should keep raising one exception, and wrapping it in a group just makes callers' lives harder.
Conclusion
ExceptionGroup lets one exception carry many, and except* lets you handle each kind of error inside it with its own clause. Several except* clauses can run for a single group, matching sees through nested groups, and anything you don't handle is re-raised automatically, so errors can't quietly disappear.
Remember the rules: no mixing except and except* on one try, no break/continue/return in an except* block, and plain except SomeError won't match a group. Use split(), subgroup(), and a small flattening helper when you need to work with groups as values, and override derive() if you subclass. Pair groups with well-named error types from Creating Custom Exceptions in Python for Clearer Error Handling, and the reports you get from concurrent or batch code become far easier to act on.


