39.7 Exception Groups and except* (Python 3.11+)
Exception Groups, introduced in Python 3.11 alongside the new except* syntax, represent a paradigm shift in how Python handles multiple, unrelated errors simultaneously. This feature was primarily developed to support the TaskGroup in the asyncio module, where multiple concurrent tasks can fail independently. However, its utility extends to any context where operations can generate several errors that should be propagated together rather than having the first raised exception mask all others.
The Problem with Traditional Exception Handling
Traditional try/except blocks are designed to handle one exception at a time. When multiple operations are performed that could each fail—common in concurrent processing or when validating several inputs—a dilemma arises. If you catch the first exception, you might abandon the operation and lose the context of the other failures. For example, a data validation script might find an invalid email and raise a ValueError, but subsequent checks for a missing phone number and an invalid username are never performed, requiring the user to fix one error at a time. Exception Groups solve this by allowing all errors to be collected and raised as a single, composite exception object.
Creating and Raising Exception Groups
An ExceptionGroup is a container that holds multiple exception instances. Its constructor takes a descriptive message and a sequence of exceptions. It is raised just like any other exception.
def validate_user_data(email, phone, username):
errors = []
if "@" not in email:
errors.append(ValueError("Invalid email address"))
if not phone.isdigit():
errors.append(ValueError("Phone number must contain only digits"))
if len(username) < 3:
errors.append(ValueError("Username must be at least 3 characters"))
if errors:
raise ExceptionGroup("User data validation failed", errors)
try:
validate_user_data("user", "abc123", "a")
except ExceptionGroup as eg:
print(f"Caught an exception group with {len(eg.exceptions)} errors:")
for i, error in enumerate(eg.exceptions, 1):
print(f" Error {i}: {type(error).__name__}: {error}")
Output:
Caught an exception group with 3 errors:
Error 1: ValueError: Invalid email address
Error 2: ValueError: Phone number must contain only digits
Error 3: ValueError: Username must be at least 3 characters
Handling Exception Groups with except*
The traditional except clause cannot match individual exceptions nested inside an ExceptionGroup. This is where the new except* clause is essential. It performs a “pattern match” on the group, catching the ExceptionGroup itself and then filtering the contained exceptions based on the specified type. Crucially, if an except* handler does not catch all exceptions in the group, the remaining exceptions are automatically re-raised as a new, smaller ExceptionGroup, ensuring no error is silently ignored.
try:
validate_user_data("user", "abc123", "a")
except* ValueError as eg:
print(f"Caught {len(eg.exceptions)} ValueError(s):")
for error in eg.exceptions:
print(f" - {error}")
# The above handler catches all exceptions in this case, so nothing is re-raised.
Partial Handling and Residual Groups
A key strength of except* is its ability to partially handle a group. If multiple except* clauses are present, each can handle a subset of the contained exceptions. Any unhandled exceptions are bundled into a new ExceptionGroup and propagated up the call stack.
def complex_operation():
errors = []
try:
int('abc') # This will cause a ValueError
except ValueError as e:
errors.append(e)
try:
1 / 0 # This will cause a ZeroDivisionError
except ZeroDivisionError as e:
errors.append(e)
if errors:
raise ExceptionGroup("Multiple operations failed", errors)
try:
complex_operation()
except* ValueError as eg:
print(f"Handling ValueErrors: {eg.exceptions}")
except* ZeroDivisionError as eg:
print(f"Handling ZeroDivisionErrors: {eg.exceptions}")
In this scenario, the first except* ValueError clause catches the ValueError from int('abc'). The ZeroDivisionError remains unhandled by this clause, so it is re-raised in a new group. The next except* ZeroDivisionError clause then catches this residual group containing only the ZeroDivisionError.
Nesting Exception Groups
ExceptionGroup objects can themselves contain other ExceptionGroup objects, creating a nested hierarchy. This is particularly useful in complex concurrent applications where tasks have subtasks. The except* syntax is designed to handle this nesting seamlessly; it recursively traverses the tree of exceptions, matching against each individual leaf exception.
# Simulate a nested error scenario
inner_group_1 = ExceptionGroup("Inner DB errors", [ConnectionError("DB1 down")])
inner_group_2 = ExceptionGroup("Inner API errors", [TimeoutError("API timed out")])
outer_group = ExceptionGroup("Root failure", [inner_group_1, inner_group_2])
try:
raise outer_group
except* ConnectionError as eg:
print(f"Recovered from DB issue: {eg.exceptions[0]}")
except* TimeoutError as eg:
print(f"Recovered from API timeout: {eg.exceptions[0]}")
Best Practices and Common Pitfalls
- Specificity over Generality: Always aim to catch the most specific exception type possible with
except*. Usingexcept* Exceptionwill catch everything, which is rarely the desired behavior as it can mask other critical errors you haven’t anticipated. - Don’t Mix
exceptandexcept*: Avoid using the traditionalexceptand newexcept*in the sametryblock. A traditionalexcept ExceptionGroup as egwill catch the group but give you no mechanism to handle its individual components elegantly, forcing you to manually iterate througheg.exceptions. Theexcept*syntax is the designated tool for this {{< bibleref “Job 3 ” >}}. Handle All Errors: Remember that any exception not caught by anexcept*clause will be re-raised. Your code should either have a catch-allexcept*clause or be prepared to handle a residualExceptionGroupat a higher level. - Use for Truly Concurrent Errors: The primary use case is for aggregating errors from concurrent operations. For sequential operations where you want to abort on the first error, traditional exception handling remains more appropriate.