Exception Groups in Python — Core Concepts
Why exception groups matter
Python’s traditional error model is one-at-a-time: raise one exception, catch one exception. This works perfectly for sequential code, but falls apart when multiple operations can fail simultaneously — concurrent tasks, batch validation, multi-step cleanup.
Python 3.11 introduced ExceptionGroup (PEP 654) to handle multiple errors at once, and except* to selectively catch types within a group.
Creating an ExceptionGroup
An ExceptionGroup wraps a list of exceptions with a descriptive message:
errors = ExceptionGroup("validation failed", [
ValueError("name is required"),
TypeError("age must be an integer"),
ValueError("email format invalid"),
])
raise errors
The group carries all three errors as a single exception. Each inner exception retains its full traceback.
Catching with except*
The new except* syntax matches specific exception types from within a group:
try:
validate_form(data)
except* ValueError as eg:
print(f"Value errors: {len(eg.exceptions)}")
for err in eg.exceptions:
print(f" - {err}")
except* TypeError as eg:
print(f"Type errors: {len(eg.exceptions)}")
for err in eg.exceptions:
print(f" - {err}")
Key difference from regular except: multiple except* clauses can fire for the same ExceptionGroup. If the group contains both ValueErrors and TypeErrors, both handlers run. Each handler receives a sub-group containing only the matched exceptions.
except* vs except: the differences
| Feature | except | except* |
|---|---|---|
| Matches | One exception type | Multiple from a group |
| Multiple handlers fire | No — first match wins | Yes — all matching handlers run |
| Receives | The exception | A sub-group of matching exceptions |
| Works with ExceptionGroup | Catches the whole group | Catches individual types within it |
| Re-raises unmatched | No — unmatched is unhandled | Yes — unmatched types propagate |
Where exception groups appear
asyncio.TaskGroup
The primary driver for this feature. When multiple tasks fail in a TaskGroup, the errors are bundled:
import asyncio
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(operation_a()) # raises ValueError
tg.create_task(operation_b()) # raises ConnectionError
except* ValueError as eg:
handle_validation_errors(eg.exceptions)
except* ConnectionError as eg:
handle_network_errors(eg.exceptions)
Batch validation
Collect all validation errors before reporting:
def validate_config(config: dict) -> None:
errors = []
if "host" not in config:
errors.append(KeyError("host"))
if not isinstance(config.get("port"), int):
errors.append(TypeError("port must be int"))
if config.get("timeout", 0) < 0:
errors.append(ValueError("timeout must be positive"))
if errors:
raise ExceptionGroup("config validation failed", errors)
Cleanup failures
When multiple cleanup operations can fail:
async def shutdown(resources: list[Resource]) -> None:
errors = []
for resource in resources:
try:
await resource.close()
except Exception as e:
errors.append(e)
if errors:
raise ExceptionGroup("shutdown errors", errors)
Splitting and filtering
ExceptionGroup provides methods for programmatic filtering:
group = ExceptionGroup("mixed", [
ValueError("bad value"),
ConnectionError("network down"),
ValueError("another bad value"),
])
value_errors, rest = group.split(ValueError)
# value_errors: ExceptionGroup with 2 ValueErrors
# rest: ExceptionGroup with 1 ConnectionError (or None if empty)
The .subgroup() method returns only the matching exceptions (or None), while .split() returns both matching and non-matching halves.
Common misconception
“ExceptionGroup replaces regular exceptions.”
Regular exceptions are still the right choice when only one error can occur at a time. ExceptionGroup is specifically for situations where multiple independent failures happen and you want to report all of them. Don’t wrap a single exception in a group — that’s overhead for no benefit.
Nesting
ExceptionGroups can nest — a group can contain other groups:
inner = ExceptionGroup("db errors", [ConnectionError("timeout")])
outer = ExceptionGroup("batch failed", [ValueError("bad input"), inner])
The except* syntax handles nesting automatically — except* ConnectionError reaches into nested groups to find matching exceptions.
Compatibility notes
- Python 3.11+ is required for native ExceptionGroup and except*
- The
exceptiongroupbackport package provides ExceptionGroup for Python 3.7+, but except* syntax isn’t available on older versions - Libraries like Trio used their own MultiError before PEP 654 standardized the approach
The one thing to remember: ExceptionGroup lets Python report all errors at once instead of just the first one, and except* lets you sort through them by type — essential for concurrent programs where multiple things can fail simultaneously.
See Also
- Python 310 New Features Python 3.10 gave programmers a shape-sorting machine, friendlier error messages, and cleaner ways to say 'this or that' in type hints.
- Python 311 New Features Python 3.11 made everything faster, error messages smarter, and let you catch several mistakes at once instead of stopping at the first one.
- Python 312 New Features Python 3.12 made type hints shorter, f-strings more powerful, and started preparing Python's engine for a world without the GIL.
- Python 313 New Features Python 3.13 finally lets multiple tasks run at the same time for real, added a speed booster engine, and gave the interactive prompt a colourful makeover.
- Python Free Threading Nogil Python has always had a rule that only one thing can happen at a time — free threading finally changes that, like opening extra checkout lanes at the grocery store.