Your signup form has three fields. If the validator raises at the first bad field, the user fixes it, submits again, and only then hears about the next one. A validator that checks every field first shows the user every problem after one submit.
Collect the errors in a list as you go, and raise them together at the end:
def validate(form: dict[str, str]) -> None:
errors: list[Exception] = []
if not form.get("name"):
errors.append(KeyError("name"))
if "@" not in form.get("email", ""):
errors.append(ValueError("email needs an @"))
if not form.get("age", "").isdigit():
errors.append(ValueError("age must be a whole number"))
if errors:
raise ExceptionGroup("invalid signup form", errors)
validate({"email": "ada.example.com", "age": "36"})
This form is missing a name and has a bad email. The end of the traceback shows the group and each error inside it:
| ExceptionGroup: invalid signup form (2 sub-exceptions)
+-+---------------- 1 ----------------
| KeyError: 'name'
+---------------- 2 ----------------
| ValueError: email needs an @
+------------------------------------
An exception group is one exception that holds a list of other
exceptions. ExceptionGroup(message, errors) takes a message and a non-empty
list of Exception instances. Its exceptions attribute holds the errors.
The group is itself an Exception, so except Exception catches it whole.
Handle each kind of error with except*
except* looks inside a group. Each clause takes the errors that match its
type and leaves the rest for the next clause. Replace the last line of the
example with this try statement:
problems: list[str] = []
try:
validate({"email": "ada.example.com", "age": "36"})
except* KeyError as missing:
problems += [f"{exc.args[0]} is required" for exc in missing.exceptions]
except* ValueError as invalid:
problems += [str(exc) for exc in invalid.exceptions]
assert problems == ["name is required", "email needs an @"]
Both clauses run here, once each. The name a clause binds, such as missing,
is a smaller group that holds only the matching errors, even for a single
match. Read the errors from its exceptions. Python raises any unmatched
errors again after the clauses finish, still inside a group.
Python enforces a few rules:
- A
trystatement takesexceptclauses orexcept*clauses. Pick one kind. - Each
except*names a type, so a bareexcept*:is a syntax error. - A
return,breakorcontinueinside anexcept*clause is a syntax error. Store what you need in a variable, and act on it after thetrystatement. except* ExceptionGroupraisesTypeError. To catch a whole group, use a plainexcept ExceptionGroup.
Check your understanding
If validate finds only a bad email, does except ValueError: around the call catch what it raises?
Show answer
Hide answer
No. validate raises an ExceptionGroup even when it holds one error, and a
group is not a ValueError. So the clause skips it, and the group
escapes. Use except* ValueError, which looks inside the group, or catch
ExceptionGroup and read its exceptions.
Split a group and keep its shape
A group can hold other groups, so a group is a tree. group.split(SomeType)
returns two groups, one with the matching errors and one with the rest. Both
keep the nesting, messages and notes of the original:
inner = ExceptionGroup("file b", [OSError("disk"), ValueError("row 7")])
group = ExceptionGroup("import", [ValueError("row 1"), inner])
bad_rows, rest = group.split(ValueError)
print(repr(bad_rows))
print(repr(rest))
ExceptionGroup('import', [ValueError('row 1'), ExceptionGroup('file b', [ValueError('row 7')])])
ExceptionGroup('import', [ExceptionGroup('file b', [OSError('disk')])])
If one side would be empty, split returns None for it, because a group
must hold at least one error. The errors inside are the original objects.
group.subgroup(SomeType) returns only the matching side.
Use BaseExceptionGroup for Ctrl-C and sys.exit()
An ExceptionGroup holds only Exception instances. To include an exception
such as KeyboardInterrupt, use BaseExceptionGroup. Its constructor picks
the narrower class when it can:
ordinary = BaseExceptionGroup("batch", [ValueError("bad row")])
mixed = BaseExceptionGroup("batch", [ValueError("bad row"), KeyboardInterrupt("stop")])
assert type(ordinary) is ExceptionGroup
assert type(mixed) is BaseExceptionGroup
assert not isinstance(mixed, Exception)
A BaseExceptionGroup is not an Exception, so except Exception lets
it pass, the same way it lets a lone KeyboardInterrupt pass. Ctrl-C still
stops the program when it arrives inside a group.
The next two exercises practice this. Collect Every Failure in a Batch uses
except*, and Split an Exception Group Without Losing Its Shape uses
split().
Going further
Exception groups arrived in Python 3.11, mainly for concurrent code, where
several tasks can fail at once. asyncio's TaskGroup runs tasks at the same
time, and when some of them fail, it raises all their errors in one group.
The concurrency track, Concurrency with Threads and asyncio, shows it in use.