Skip to content
← All Python articles

Python explainer

Report Several Errors at Once with ExceptionGroup

About 4 min to read. Allow extra time to work through examples.

Check every field before you raise, report all the errors together in an ExceptionGroup, and handle each kind of error with except*.

This article uses Python. You can make it your practice mode to browse related content across the site.

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 try statement takes except clauses or except* clauses. Pick one kind.
  • Each except* names a type, so a bare except*: is a syntax error.
  • A return, break or continue inside an except* clause is a syntax error. Store what you need in a variable, and act on it after the try statement.
  • except* ExceptionGroup raises TypeError. To catch a whole group, use a plain except ExceptionGroup.
Check your understanding If validate finds only a bad email, does except ValueError: around the call catch what it raises? Show 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.

step 10 of 20

Error Handling with Exceptions, Retries and Warnings

Up next Collect Every Failure in a Batch