Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Python  Module 7 – Errors, exceptions and context managers

Exception groups and except*

Raise several exceptions at once with ExceptionGroup, handle each kind with except*, filter groups with split() and subgroup(), and read TaskGroup errors.

  • Intermediate
  • 15 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Build ExceptionGroup objects and inspect what they hold
  • Handle the exceptions of a group with except*
  • Filter nested groups with split() and subgroup()
  • Explain why concurrent code can report several errors at once
  • Choose between except and except* for code that may raise groups

Before you start

What you’ll learn · Before you start

What you will learn

  • Build ExceptionGroup objects and inspect what they hold
  • Handle the exceptions of a group with except*
  • Filter nested groups with split() and subgroup()
  • Explain why concurrent code can report several errors at once
  • Choose between except and except* for code that may raise groups

Before you start

On this page

Sometimes one exception is not enough. A form with three bad fields should say so all at once, not one fix at a time, and when five downloads run side by side, two of them can fail at the same moment. Since Python 3.11, an exception group: An exception that carries several others, made with ExceptionGroup(message, exceptions). except* handles each kind of exception inside it, and a group can hold other groups. carries several exceptions together: raise ExceptionGroup("the form has errors", errors) raises a list of them as one exception. The except* clause takes a group apart by type, so except* ValueError: handles every ValueError inside the group and lets the rest travel on. Groups are ordinary exceptions otherwise: they have a message, they can be nested, and their methods split() and subgroup() filter them.

Below you will raise groups, step through except* taking one apart, filter nested groups by hand, and follow where most groups come from: tasks that run at the same time.

An ExceptionGroup raises several exceptions as one

ExceptionGroup(message, exceptions) takes a message and a non-empty list of exception objects. Validation is the everyday use: check every field, collect what is wrong, and raise it all together.

Every bad field of a form in one traceback Python · form_errors.py
class FieldError(ValueError):
    def __init__(self, field, problem):
        super().__init__(field, problem)
        self.field = field
        self.problem = problem

    def __str__(self):
        return f"{self.field}: {self.problem}"


def validate_signup(form):
    errors = []
    if not form.get("name", "").strip():
        errors.append(FieldError("name", "is empty"))
    if "@" not in form.get("email", ""):
        errors.append(FieldError("email", "needs an @"))
    age = form.get("age")
    if not isinstance(age, int) or not 13 <= age <= 120:
        errors.append(FieldError("age", f"must be a whole number from 13 to 120, got {age!r}"))
    if errors:
        raise ExceptionGroup("the sign-up form has errors", errors)
    return form


print(validate_signup({"name": "Kiran", "email": "kiran@example.com", "age": 19})["name"], "signed up")
validate_signup({"name": " ", "email": "kiran.example.com", "age": "19"})

Output (exit status 1)

Kiran signed up

Printed as an error (standard error)

  + Exception Group Traceback (most recent call last):
  |   File "form_errors.py", line 26, in <module>
  |     validate_signup({"name": " ", "email": "kiran.example.com", "age": "19"})
  |     ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |   File "form_errors.py", line 21, in validate_signup
  |     raise ExceptionGroup("the sign-up form has errors", errors)
  | ExceptionGroup: the sign-up form has errors (3 sub-exceptions)
  +-+---------------- 1 ----------------
    | FieldError: name: is empty
    +---------------- 2 ----------------
    | FieldError: email: needs an @
    +---------------- 3 ----------------
    | FieldError: age: must be a whole number from 13 to 120, got '19'
    +------------------------------------

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 form_errors.py

The traceback shows the group’s message with a count, “(3 sub-exceptions)”, then each exception it holds in a numbered box, so a single run reports everything that needs fixing. A few rules apply when you build one:

  • The list must not be empty; ExceptionGroup("x", []) raises ValueError.
  • It holds exception objects, not classes, and only subclasses of Exception. A group that has to carry a KeyboardInterrupt is a BaseExceptionGroup, which except Exception: does not catch.
  • The group keeps its message in .message and its exceptions, as a tuple, in .exceptions. A group can hold other groups, which is how a form with an address section can report the address’s own errors as one entry.

FieldError derives from ValueError, so each entry is still recognisable as a bad value, and it keeps the field’s name as an attribute for whoever reports the errors.

A group for an error that is always alone

A group earns its place when there can be several independent errors, as in a form or a set of tasks. A function that can only ever fail in one way is clearer raising that one ValueError itself.

Checkpoint

Check: Raising several exceptions as one group

  1. One question How many sub-exceptions does the traceback of form_errors.py report for the second form?

    Type a number.

    A hint

    Count the fields that are wrong in the second call, or read the line that starts with ExceptionGroup.

    Show the answer: One question

    Answer: 3 sub-exceptions

    The name is blank, the email has no @ and the age is the text "19", so validate_signup() collects three FieldErrors and raises them together: ExceptionGroup: the sign-up form has errors (3 sub-exceptions).

except* handles each kind in a group once

except* clauses take a group apart by type. This program checks orders that can be wrong in several ways at once:

One clause per kind of problem Python · except_star.py
def check(order):
    problems = []
    if not isinstance(order.get("qty"), int):
        problems.append(TypeError(f"qty must be an int, got {type(order.get('qty')).__name__}"))
    elif order["qty"] < 1:
        problems.append(ValueError(f"qty must be at least 1, got {order['qty']}"))
    if order.get("coupon") not in (None, "WELCOME10"):
        problems.append(ValueError(f"unknown coupon {order['coupon']!r}"))
    if "address" not in order:
        problems.append(KeyError("address"))
    if problems:
        raise ExceptionGroup(f"order {order['id']} is invalid", problems)


orders = [
    {"id": 1, "qty": 0, "coupon": "FREE", "address": "Pune"},
    {"id": 2, "qty": "2", "address": "Kochi"},
    {"id": 3, "qty": 1},
]
for order in orders:
    try:
        check(order)
    except* ValueError as group:
        print(f"order {order['id']}, bad values:", "; ".join(str(e) for e in group.exceptions))
    except* TypeError as group:
        print(f"order {order['id']}, wrong type:", group.exceptions[0])

Output (exit status 1)

order 1, bad values: qty must be at least 1, got 0; unknown coupon 'FREE'
order 2, wrong type: qty must be an int, got str

Printed as an error (standard error)

  + Exception Group Traceback (most recent call last):
  |   File "except_star.py", line 22, in <module>
  |     check(order)
  |     ~~~~~^^^^^^^
  |   File "except_star.py", line 12, in check
  |     raise ExceptionGroup(f"order {order['id']} is invalid", problems)
  | ExceptionGroup: order 3 is invalid (1 sub-exception)
  +-+---------------- 1 ----------------
    | KeyError: 'address'
    +------------------------------------

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 except_star.py

Order 1 has two bad values, and the except* ValueError clause ran once, with a group of both of them in group. Order 2 had a wrong type, which the second clause handled. Order 3 lacked its address: no clause names KeyError, so after the clauses had run, Python raised what was left, a group of one, and the program ended there. That is the difference from except: several clauses can run for one group, each for its own part, and nothing is silently dropped. A group with all four kinds of problem at once goes through the clauses like this:

A group of two ValueErrors, a TypeError and a KeyError passes two except* clauses; the unmatched KeyError is raised again.the group raised:ValueError,ValueError,TypeError,KeyErrorexcept* ValueError:runs once, withboth ValueErrorsexcept* TypeError:runs once, withthe TypeErrorKeyError matchedno clause: raisedagain, as a groupof onesplitthe restthe rest

How except* clauses split an exception group

Text description of the diagram

The diagram is one column of four boxes, read from top to bottom.

  • At the top is the exception group that was raised. It holds two ValueErrors, a TypeError and a KeyError.
  • The first clause, except* ValueError, splits the group: it takes the two ValueErrors and runs once, with a group that holds just those two.
  • The rest of the group, the TypeError and the KeyError, goes on to the next clause, except* TypeError, which takes the TypeError and runs once with it.
  • The KeyError matched no clause. After the clauses have run, Python raises it again, inside a group of one with the original group's message, so the caller can still see it.

Step through that group, built from the problems of the three orders, and watch which clauses run and what group holds in each. An outer try statement catches whatever the clauses leave, so you can see it:

Interactive · line by line One group, two except* clauses Step through the program. Watch which clauses run, what group holds in each, and what is left when both have run.

Program · star_trace.py

errors = [ValueError("qty is 0")]
errors.append(ValueError("unknown coupon"))
errors.append(TypeError("qty is a str"))
errors.append(KeyError("address"))
try:
    try:
        raise ExceptionGroup("order 4", errors)
    except* ValueError as group:
        print("values:", len(group.exceptions))
    except* TypeError as group:
        print("types:", len(group.exceptions))
except ExceptionGroup as rest:
    print("left over:", rest.exceptions)
print("done")

Variables

After line 14: errors is [ValueError('qty is 0'), ValueError('unknown coupon'), TypeError('qty is a str'), KeyError('address')].errorsValueError('qty is 0')0ValueError('unknown coupon')1TypeError('qty is a str')2KeyError('address')3
  • changed in this step

Printed so far

values: 2
types: 1
left over: (KeyError('address'),)
done

Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.

The drawing shows the variables when the program ends, and the program's 15 steps are listed below.

Line run 14
Next line end
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 1.
  2. Line 1. errors = [ValueError("qty is 0")] makes errors a list of 1 item.
  3. Line 2. errors.append(ValueError("unknown coupon")) adds ValueError('unknown coupon') to the end of errors.
  4. Line 3. errors.append(TypeError("qty is a str")) adds TypeError('qty is a str') to the end of errors.
  5. Line 4. errors.append(KeyError("address")) adds KeyError('address') to the end of errors.
  6. Line 5. try: runs.
  7. Line 6. try: runs.
  8. Line 7. raise ExceptionGroup("order 4", errors) runs. The group holds four exceptions, two of them ValueError, one TypeError and one KeyError: the group of the diagram above.
  9. Line 8. Line 8 runs. Key point except* ValueError splits the group. Both of its ValueError exceptions go into group, a new ExceptionGroup with the message "order 4", and the clause runs once for the two.
  10. Line 9. print("values:", len(group.exceptions)) prints values: 2. group no longer exists.
  11. Line 10. Line 10 runs. The rest, the TypeError and the KeyError, reaches the next clause, which takes the TypeError.
  12. Line 11. print("types:", len(group.exceptions)) prints types: 1. group no longer exists.
  13. Line 12. Line 12 runs. Key point Both clauses ran, once each. The KeyError matched neither, so Python raised it again in a group of one with the message "order 4", and the plain except of the outer statement catches that group whole.
  14. Line 13. print("left over:", rest.exceptions) prints left over: (KeyError('address'),). rest no longer exists.
  15. Line 14. print("done") prints done. That was the last line, so the program ends.

Python Online Compiler Run except_star.py and add an except* KeyError clause, so that order 3 is reported like the others.

Key idea

Each except* clause runs at most once, with a group of every exception it matches; the rest goes on to the next clause, and whatever no clause matches is raised again.

Checkpoint

Check: How except* takes a group apart

  1. One question In except_star.py, why does order 3 end the program instead of printing a line?

    Choose one answer.

    Show the answer: One question

    Answer: Its only problem is a KeyError, which no except* clause names, so the group is raised again

    Order 3 lacks its address, so check() raises a group holding one KeyError. Neither except* clause matches it, so Python raises the group again after the clauses, and nothing outside the loop handles it.

except* follows stricter rules than except

A clause that may share a group with other clauses has to keep to a few rules that plain except does not have. This program shows the first two:

A plain exception, and one clause for two types Python · star_rules.py
def risky(kind):
    if kind == "single":
        raise ValueError("a single error")
    raise ExceptionGroup("two errors", [TypeError("bad type"), OSError("disk full")])


try:
    risky("single")
except* ValueError as group:  # a plain exception arrives wrapped in a group
    print("wrapped:", repr(group))

try:
    risky("group")
except* TypeError, OSError:  # Python 3.14: no brackets needed without "as"
    print("one clause, run once, for both errors")

Output

wrapped: ExceptionGroup('', (ValueError('a single error'),))
one clause, run once, for both errors

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 star_rules.py

  • An exception that is not a group still reaches an except* clause, wrapped in a group with an empty message, as the first line shows. Inside an except* clause the name always refers to a group.
  • A clause with several types runs once for all of them together. Since Python 3.14 the brackets around the types can go, as with except, as long as there is no as.
  • One try statement uses either except or except*, never both: mixing them is a SyntaxError.
  • except* ExceptionGroup: is refused with a TypeError, because it would be unclear which part it means; catch a whole group with plain except ExceptionGroup: instead.
  • break, continue and return cannot appear in an except* clause, because other clauses may still have to run.

Checkpoint

Check: The rules of except*

  1. One question The first try statement of star_rules.py raises a plain ValueError. What does group refer to in its except* ValueError as group: clause?

    Choose one answer.

    Show the answer: One question

    Answer: An ExceptionGroup with an empty message that holds the ValueError

    The output reads wrapped: ExceptionGroup('', (ValueError('a single error'),)). Python wraps a plain exception that matches an except* clause in a group with an empty message.

split() and subgroup() filter a group and keep its nesting

except* is built on two methods that you can call yourself, for example when one function collects errors and another one reports them. group.subgroup(condition) keeps the exceptions that match, and group.split(condition) returns the match and the rest as a pair. The condition is an exception type, a tuple of types, or a function that takes an exception and returns true for the ones to keep:

Splitting a nested group Python · split_subgroup.py
report = ExceptionGroup(
    "profile has errors",
    [
        ValueError("name is empty"),
        TypeError("age must be an int"),
        ExceptionGroup("address has errors", [ValueError("PIN code needs 6 digits"), KeyError("city")]),
    ],
)

values, rest = report.split(ValueError)
print("match:", repr(values))
print("rest: ", repr(rest))
print("keys: ", repr(report.subgroup(lambda e: isinstance(e, KeyError))))
print("none: ", report.subgroup(OSError))


def leaves(group):
    """Every exception inside a group, however deeply nested, in order."""
    found = []
    for exc in group.exceptions:
        if isinstance(exc, BaseExceptionGroup):
            found.extend(leaves(exc))
        else:
            found.append(exc)
    return found


print([f"{type(e).__name__}: {e}" for e in leaves(report)])

Output

match: ExceptionGroup('profile has errors', [ValueError('name is empty'), ExceptionGroup('address has errors', [ValueError('PIN code needs 6 digits')])])
rest:  ExceptionGroup('profile has errors', [TypeError('age must be an int'), ExceptionGroup('address has errors', [KeyError('city')])])
keys:  ExceptionGroup('profile has errors', [ExceptionGroup('address has errors', [KeyError('city')])])
none:  None
['ValueError: name is empty', 'TypeError: age must be an int', 'ValueError: PIN code needs 6 digits', "KeyError: 'city'"]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 split_subgroup.py

Step through the calls of the program on its nested report:

Interactive · step by step Splitting a nested group Step through the calls of split_subgroup.py. The left column holds the two groups, the right one the exceptions inside them.
Splitting a nested group, step 6 of 6. Showing profile has errors, ValueError (name is empty), TypeError (age must be an int), address has errors, ValueError (PIN code needs 6 digits), KeyError (kept: KeyError 'city'). In focus: profile has errors.profile has errorsthe outer groupValueErrorname is emptyTypeErrorage must be an intaddress has errorsa group inside itValueErrorPIN code needs 6 digitsKeyErrorkept: KeyError 'city'subgroup(OSError) is None

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The report. split_subgroup.py builds one group, "profile has errors", that holds a ValueError, a TypeError and a nested group for the address, which holds a ValueError and a KeyError of its own.
  2. split(ValueError) finds both. report.split(ValueError) picks every ValueError, however deep it sits. The empty name is at the top level, and the PIN code is inside the address group.
  3. The match keeps the nesting. The match is a group "profile has errors" whose entries are the name's ValueError and a group "address has errors" holding the PIN error, the first line of the output.
  4. The rest keeps it too. Everything else is the rest, with the same shape. The TypeError of the age sits at the top, and the KeyError of the city inside its own address group.
  5. subgroup() with a test. report.subgroup(lambda e: isinstance(e, KeyError)) keeps only the city's KeyError, still inside both groups. Entries that hold no match are left out.
  6. Nothing to keep. report.subgroup(OSError) matches nothing at all, so it returns None rather than an empty group, the fourth line of the output.

Both methods keep the nesting: the ValueError of the address stays inside an “address has errors” group in the match, and the KeyError stays inside one in the rest. Groups that would be empty are left out, and a result with nothing in it is None, as subgroup(OSError) shows. When you need the exceptions themselves, however deep they sit, walk the groups with a small recursive function such as leaves(). Notes, the cause and the traceback of the original group are copied to the groups that split() and subgroup() return.

Hiding the rest

A handler that catches ExceptionGroup and looks only for the errors it knows quietly drops the others. With except*, or with split() and raise rest, the unknown ones go on to the caller.

Checkpoint

Check: Filtering with split() and subgroup()

  1. One question In split_subgroup.py, where does the city's KeyError end up after report.split(ValueError)?

    Choose one answer.

    Show the answer: One question

    Answer: In rest, inside an "address has errors" group inside "profile has errors"

    split() returns the match and the rest, each with the original nesting. The rest is ExceptionGroup('profile has errors', [TypeError(...), ExceptionGroup('address has errors', [KeyError('city')])]).

Tasks that fail together report a group

Concurrent code was the main reason exception groups were added. When several tasks run at the same time and two of them fail, there is no honest way to raise only one of the two errors. asyncio.TaskGroup runs tasks together inside an async with block. When one of them fails, it cancels the others still running, and once all have finished it raises their errors as one ExceptionGroup., the subject of the asyncio module of this track, runs tasks together and reports their failures as a group:

Two tasks fail, one is cancelled Python · taskgroup_errors.py
import asyncio


async def fetch(source, seconds, error=None):
    await asyncio.sleep(seconds)
    if error:
        raise error
    print(f"{source}: done")


async def main():
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch("prices", 0))
        tg.create_task(fetch("stock", 0, ConnectionError("the stock service refused the connection")))
        tg.create_task(fetch("reviews", 0, TimeoutError("reviews took too long")))
        tg.create_task(fetch("images", 10))  # still running when the others fail, so it is cancelled


try:
    asyncio.run(main())
except* ConnectionError as group:
    print(f"{group.message}: retry later:", [str(e) for e in group.exceptions])
except* TimeoutError as group:
    print(f"{group.message}: timed out:", [str(e) for e in group.exceptions])

Output

prices: done
unhandled errors in a TaskGroup: retry later: ['the stock service refused the connection']
unhandled errors in a TaskGroup: timed out: ['reviews took too long']

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 taskgroup_errors.py

The prices task finished. The stock and reviews tasks both failed, and the task group collected both errors into an ExceptionGroup with the message “unhandled errors in a TaskGroup”. When the first error arrived, the group cancelled the images task, which was still waiting, so it neither finished nor added an error of its own. Each except* clause then dealt with one kind of failure: retry later, or report a timeout. Step through the run:

Interactive · step by step Four tasks, two failures, one group Step through the run of taskgroup_errors.py. The left column holds the tasks, the right one what the TaskGroup and the except* clauses do.
Four tasks, two failures, one group, step 7 of 7. Showing prices (done), stock (raised ConnectionError), reviews (raised TimeoutError), images (cancelled), the TaskGroup, ExceptionGroup, except* ConnectionError, except* TimeoutError. In focus: except* TimeoutError.ConnectionErrorTimeoutErrorsplitthe restpricesdonestockraised ConnectionErrorreviewsraised TimeoutErrorimagescancelledthe TaskGroupwaits for all fourExceptionGroup2 sub-exceptionsexcept* ConnectionErrorprints retry laterexcept* TimeoutErrorprints timed outTimeoutError

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Four tasks start. main() creates four tasks in one asyncio.TaskGroup. prices, stock and reviews wait 0 seconds; images waits 10.
  2. prices finishes. prices wakes up and prints "prices: done", the first line of the output.
  3. Two tasks fail. stock raises ConnectionError and reviews raises TimeoutError. Neither task handles its error, so both reach the TaskGroup.
  4. The rest is cancelled. The first failure makes the group cancel every task still running. images was still waiting, so it is cancelled and adds no error of its own.
  5. One group for both errors. Once all four tasks have finished, the TaskGroup combines the two errors in an ExceptionGroup with the message "unhandled errors in a TaskGroup", and raises it.
  6. Retry later. except* ConnectionError takes the stock error out of the group and prints that the stock service should be tried again later.
  7. Timed out. except* TimeoutError takes what is left, the reviews error. Both clauses have run once, nothing remains, and the program ends normally.

Reading meaning into the order of the errors

In concurrent code the order of the errors in a group depends on which task failed first. Look errors up by type, with except* or split(), or by an attribute, never by their position in .exceptions.

Checkpoint

Check: Groups from tasks that fail together

  1. One question What did the asyncio.TaskGroup of taskgroup_errors.py raise when its tasks were done?

    Choose one answer.

    Show the answer: One question

    Answer: One ExceptionGroup holding the ConnectionError and the TimeoutError

    prices finished, images was cancelled, and the two failures, the ConnectionError of stock and the TimeoutError of reviews, were combined in one ExceptionGroup, "unhandled errors in a TaskGroup".

A plain except never looks inside a group

A group is an exception like any other, so except ExceptionGroup: and even except Exception: catch it whole. What does not catch it is a clause for one of the types inside:

except ValueError does not see inside a group Python · group_vs_except.py
def validate():
    raise ExceptionGroup("2 errors", [ValueError("bad name"), ValueError("bad age")])


try:
    try:
        validate()
    except ValueError:
        print("Not printed: a group is not a ValueError.")
except ExceptionGroup as group:
    print("The group went past except ValueError:", group)
    print("except Exception would catch it too:", isinstance(group, Exception))

Output

The group went past except ValueError: 2 errors (2 sub-exceptions)
except Exception would catch it too: True

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 group_vs_except.py

Catching a group with except SomeError

except ValueError: around code that raises groups never runs, so the errors it was written for go straight past it. Write except* ValueError: instead.

except ValueError: compares the type of the exception it receives, and an ExceptionGroup is not a ValueError, whatever it contains. So when code that you call may raise groups, as a TaskGroup does, handle its failures with except*. Use plain except when you deal with the group as one thing, for example to log it before it goes on.

Note

Code that already catches single exceptions keeps working until something starts raising groups. A library that switches from raising one exception to raising a group changes what its callers must write: PEP 654 calls raising a group an API-breaking change, one that belongs in the library’s release notes.

Checkpoint

Check: except or except*

  1. One question In group_vs_except.py, which clause catches the group that validate() raises?

    Choose one answer.

    Show the answer: One question

    Answer: The outer except ExceptionGroup

    except ValueError compares the type of what it receives, an ExceptionGroup, which is not a ValueError. The outer except ExceptionGroup matches, and isinstance(group, Exception) is True, so except Exception would catch it too.

Practise: report every bad field, then summarise them

The exercise asks for validate_user(data), which checks three fields and raises one group with a FieldError for every field that is wrong, and summarise_errors(group), which turns such a group into a dictionary of problems, however deeply it is nested, and raises again whatever is not a field error. Its sample tests run in your browser:

Exercise · Medium · Python

Report every bad field, then summarise them

users.py defines FieldError(field, problem), an exception for one bad field of a form, with .field and .problem. Write two functions.

validate_user(data) checks a dictionary and returns it when every field is valid. Otherwise it raises one ExceptionGroup with the message invalid user, holding a FieldError for every bad field, in this order:

  • name must be text that is not empty after .strip(); otherwise FieldError("name", "is required").
  • email must be text with exactly one @, and something before and after it; otherwise FieldError("email", "is not an email address").
  • age must be an int (not a bool) from 13 to 120; otherwise FieldError("age", "must be a whole number from 13 to 120").

A missing key counts as a bad field. Even a single bad field is reported as a group of one.

summarise_errors(group) turns such a group into a dictionary from each field to its problem, for every FieldError in it, however deeply groups are nested inside it: a sign-up form might add the group of an address sub-form. Use group.split(FieldError) to separate the field errors from everything else. When the group also holds other exceptions, such as a TypeError from a bug, raise that rest of the group again instead of hiding it.

Starter code · users.py

class FieldError(Exception):
    """A problem with one field of a form."""

    def __init__(self, field, problem):
        super().__init__(field, problem)
        self.field = field
        self.problem = problem

    def __str__(self):
        return f"{self.field}: {self.problem}"


def validate_user(data):
    """Return data when every field is valid; otherwise raise an ExceptionGroup of FieldError, one per bad field."""
    # Replace this line with your code.
    return data


def summarise_errors(group):
    """Return {field: problem} for every FieldError in group, at any depth; raise the other exceptions again."""
    # Replace this line with your code.
    return {}
The sample tests · test_users.py
from users import FieldError, summarise_errors, validate_user

GOOD = {"name": "Kiran", "email": "kiran@example.com", "age": 19}


def group_from(data):
    """The ExceptionGroup that validate_user(data) raises, or None."""
    try:
        validate_user(data)
    except ExceptionGroup as group:
        return group
    return None


def test_valid_user():
    """returns the data when every field is valid"""
    assert validate_user(dict(GOOD)) == GOOD
    assert validate_user({"name": " Ana ", "email": "a@b.in", "age": 13})["age"] == 13


def test_every_bad_field():
    """reports all three bad fields at once, in order"""
    group = group_from({"name": "  ", "email": "kiran.example.com", "age": 7})
    assert group is not None
    assert group.message == "invalid user"
    assert [type(e) for e in group.exceptions] == [FieldError, FieldError, FieldError]
    assert [e.field for e in group.exceptions] == ["name", "email", "age"]


def test_one_bad_field_is_still_a_group():
    """raises a group of one for a single bad field"""
    group = group_from({"name": "Kiran", "email": "kiran@@example.com", "age": 19})
    assert [(e.field, e.problem) for e in group.exceptions] == [("email", "is not an email address")]


def test_missing_and_odd_values():
    """treats missing keys, a bool age and an @ at the edge as bad fields"""
    assert [e.field for e in group_from({}).exceptions] == ["name", "email", "age"]
    assert [e.field for e in group_from({**GOOD, "age": True}).exceptions] == ["age"]
    assert [e.field for e in group_from({**GOOD, "email": "kiran@"}).exceptions] == ["email"]
    assert [e.field for e in group_from({**GOOD, "age": 121}).exceptions] == ["age"]


def test_summarise_flat_group():
    """maps each field to its problem"""
    group = group_from({"name": "", "email": "kiran@example.com", "age": "19"})
    assert summarise_errors(group) == {"name": "is required", "age": "must be a whole number from 13 to 120"}


def test_summarise_nested_groups():
    """finds field errors at any depth"""
    address = ExceptionGroup("invalid address", [FieldError("pin", "needs 6 digits"), FieldError("city", "is required")])
    group = ExceptionGroup("invalid sign-up", [FieldError("name", "is required"), ExceptionGroup("sub-forms", [address])])
    assert summarise_errors(group) == {"name": "is required", "pin": "needs 6 digits", "city": "is required"}


def test_summarise_raises_the_rest():
    """raises the exceptions that are not field errors again, as a group"""
    group = ExceptionGroup("mixed", [FieldError("name", "is required"), TypeError("a bug")])
    try:
        summarise_errors(group)
    except ExceptionGroup as rest:
        assert [type(e) for e in rest.exceptions] == [TypeError]
        return
    assert False, "summarise_errors should raise the TypeError again"
A hint

In validate_user, collect the errors in a list, one if per field, and raise ExceptionGroup("invalid user", errors) at the end only when the list is not empty. type(age) is int is false for True, which isinstance(True, int) would accept. In summarise_errors, start with fields, rest = group.split(FieldError); rest is None when there is nothing else, and fields keeps the nesting, so walk it with a small recursive function that collects the exceptions inside every nested group.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

To check yourself on groups, except*, splitting and task groups, answer the questions below. They follow your answers, weakest idea first, with a reason under every choice:

Practice that adapts to you

Practise this lesson

19 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 19 What does ExceptionGroup("the form has errors", []) do?

    Choose one answer.

    Show the answer: Question 1 of 19

    Answer: It raises ValueError, because a group needs at least one exception

    ExceptionGroup(message, exceptions) needs a non-empty sequence of exception objects; an empty list raises ValueError, so collect the errors first and raise a group only when there are some.

  2. Question 2 of 19 What does this print?

    Read the code, then choose one answer.

    g = ExceptionGroup("form", [ValueError("name"), TypeError("age")])
    print(g.message, len(g.exceptions), type(g.exceptions).__name__)
    Show the answer: Question 2 of 19

    Answer: form 2 tuple

    .message is the message the group was made with, and .exceptions is a tuple of what it holds. The "(2 sub-exceptions)" of a traceback is added when the group is printed.

  3. Question 3 of 19 A group must carry a KeyboardInterrupt together with a ValueError. Which class can hold them, and does except Exception: catch it?

    Choose one answer.

    Show the answer: Question 3 of 19

    Answer: BaseExceptionGroup, and except Exception does not catch it

    ExceptionGroup holds only subclasses of Exception. A group that has to carry a KeyboardInterrupt is a BaseExceptionGroup, which except Exception does not catch, just as it does not catch the KeyboardInterrupt alone.

  4. Question 4 of 19 A group holds three ValueErrors. How many times does except* ValueError: run?

    Choose one answer.

    Show the answer: Question 4 of 19

    Answer: Once, with a group of all three

    except* splits the group into the part that matches and the rest. The matching part, all three ValueErrors in one group, is handed to the clause, which runs once.

  5. Question 5 of 19 A group holds a ValueError and a KeyError. The try statement has one clause, except* ValueError:, which prints a line. What happens?

    Choose one answer.

    Show the answer: Question 5 of 19

    Answer: The clause runs, then a group holding the KeyError is raised again

    except* handles the part of the group that matches and passes the rest on. After all the clauses have run, whatever is left is raised again, as a group with the original message.

  6. Question 6 of 19 What does this print?

    Read the code, then choose one answer.

    try:
        raise ExceptionGroup("g", [ValueError(1), TypeError(2), ValueError(3)])
    except* ValueError as e:
        print("V", len(e.exceptions))
    except* TypeError as e:
        print("T", len(e.exceptions))
    Show the answer: Question 6 of 19

    Answer: V 2 T 1

    The first clause splits off both ValueErrors and runs once; the rest, the TypeError, goes on to the second clause, which runs once too. Nothing is left, so nothing is raised again.

  7. Question 7 of 19 What does this print?

    Read the code, then choose one answer.

    try:
        raise ExceptionGroup("io", [FileNotFoundError("a.csv"), TimeoutError("slow disk")])
    except* OSError as e:
        print("OSError clause:", [type(x).__name__ for x in e.exceptions])
    Show the answer: Question 7 of 19

    Answer: OSError clause: ['FileNotFoundError', 'TimeoutError']

    except* splits a group with the same rule except uses: a clause matches its class and every subclass. Both FileNotFoundError and TimeoutError are kinds of OSError, so the clause runs once with both.

  8. Question 8 of 19 Which of these are errors?

    Choose every answer that is right.

    Show the answer: Question 8 of 19

    Answer:

    • return inside an except* clause
    • except* ExceptionGroup
    • A try statement with both an except clause and an except* clause

    Mixing except and except* and returning from an except* clause are syntax errors, and except* ExceptionGroup raises TypeError when it is reached. Python 3.14 allows except* with several types and no brackets, as long as the clause has no "as".

  9. Question 9 of 19 What does star_rules.py print?

    What does this program print? Choose one answer.

    def risky(kind):
        if kind == "single":
            raise ValueError("a single error")
        raise ExceptionGroup("two errors", [TypeError("bad type"), OSError("disk full")])
    
    
    try:
        risky("single")
    except* ValueError as group:  # a plain exception arrives wrapped in a group
        print("wrapped:", repr(group))
    
    try:
        risky("group")
    except* TypeError, OSError:  # Python 3.14: no brackets needed without "as"
        print("one clause, run once, for both errors")
    Show the answer: Question 9 of 19

    Answer: it prints

    wrapped: ExceptionGroup('', (ValueError('a single error'),))
    one clause, run once, for both errors

    A plain exception that reaches an except* clause is wrapped in a group with an empty message, so the name always refers to a group. A clause that names two types runs once, with both matching exceptions, not once per exception.

  10. Question 10 of 19 A loop must stop as soon as a group it receives holds a KeyError, but break is not allowed in an except* clause. What do you write instead?

    Choose one answer.

    Show the answer: Question 10 of 19

    Answer: Set a flag in the except* KeyError clause and break after the try statement

    Other except* clauses may still have to run for the rest of the group, so an except* clause cannot jump out of the statement. Record the decision in the clause and act on it once the try statement is over.

  11. Question 11 of 19 What is rest?

    Read the code, then choose one answer.

    group = ExceptionGroup("g", [ValueError(1), ExceptionGroup("inner", [TypeError(2)])])
    match, rest = group.split(ValueError)
    Show the answer: Question 11 of 19

    Answer: ExceptionGroup('g', [ExceptionGroup('inner', [TypeError(2)])])

    split() keeps the nesting and the messages: the rest is a group with the outer message that still contains the inner group with the TypeError. A list is never returned, and None appears only when a part would be empty.

  12. Question 12 of 19 What does group.subgroup(OSError) return when nothing in the group is an OSError?

    Choose one answer.

    Show the answer: Question 12 of 19

    Answer: None

    subgroup() and split() leave out groups that would be empty, and a result with nothing in it at all is None, so test for None before you raise or report it.

  13. Question 13 of 19 A group holds a nested group. Which code gives you every exception inside, however deeply it is nested?

    Choose one answer.

    Show the answer: Question 13 of 19

    Answer: A small recursive function, like leaves(), that goes into every group it finds in .exceptions

    split() and subgroup() keep the nesting, which is right for raising the result again. To report each exception, walk the groups with a function that adds plain exceptions and goes into every BaseExceptionGroup it meets.

  14. Question 14 of 19 In taskgroup_errors.py, why did the images task neither finish nor add an error of its own?

    Choose one answer.

    Show the answer: Question 14 of 19

    Answer: It was still waiting when the first task failed, and the group cancelled it

    When a task in an asyncio.TaskGroup fails, the group cancels the tasks still running. images was waiting, so it was cancelled, and a cancelled task adds no error to the group.

  15. Question 15 of 19 In taskgroup_errors.py, what is group.message inside except* TimeoutError as group:?

    Choose one answer.

    Show the answer: Question 15 of 19

    Answer: unhandled errors in a TaskGroup

    The TaskGroup raised ExceptionGroup("unhandled errors in a TaskGroup", […]). The group each except* clause receives keeps that message, which is why both output lines start with it.

  16. Question 16 of 19 Two tasks of a TaskGroup fail. A handler treats group.exceptions[0] as the error of the first task it created. What is wrong with that?

    Choose one answer.

    Show the answer: Question 16 of 19

    Answer: The order depends on which task failed first, so look the errors up by type or by attribute

    Assume nothing about the position of an error in a group from concurrent code. Pick errors out with except* or split() by type, or by an attribute such as a field name.

  17. Question 17 of 19 A function raises ExceptionGroup("2 errors", [ValueError("a"), ValueError("b")]). Which clause catches it?

    Choose one answer.

    Show the answer: Question 17 of 19

    Answer: except* ValueError

    A group is not a ValueError, so except ValueError never matches it. except* ValueError looks inside the group, and except ExceptionGroup or except Exception would catch the whole group too.

  18. Question 18 of 19 What does group_vs_except.py print?

    What does this program print? Choose one answer.

    def validate():
        raise ExceptionGroup("2 errors", [ValueError("bad name"), ValueError("bad age")])
    
    
    try:
        try:
            validate()
        except ValueError:
            print("Not printed: a group is not a ValueError.")
    except ExceptionGroup as group:
        print("The group went past except ValueError:", group)
        print("except Exception would catch it too:", isinstance(group, Exception))
    Show the answer: Question 18 of 19

    Answer: it prints

    The group went past except ValueError: 2 errors (2 sub-exceptions)
    except Exception would catch it too: True

    except ValueError: compares the type of the exception it receives, and an ExceptionGroup is not a ValueError. The outer clause catches the group whole; printing it shows its message with the count, and it is an Exception.

  19. Question 19 of 19 A service must log every group that a library call raises, whole, before the group goes on to the caller. Which handler fits?

    Choose one answer.

    Show the answer: Question 19 of 19

    Answer: except ExceptionGroup, then log the group and re-raise it with a bare raise

    Use except* to handle the kinds inside a group, and plain except when you deal with the group as one thing, for example to log it before it goes on.

Interview questions

2–5 years · What does except* ValueError do with an ExceptionGroup that holds a ValueError and a TypeError?

It splits the group by type. The clause runs once, and its name refers to a new ExceptionGroup with the original message that holds only the ValueError, keeping any nesting. The TypeError part is passed on to the next except* clause of the same try statement. If no clause matches it, Python raises it again after the clauses have run, as a group with the original message, so it reaches the caller. Each clause runs at most once per group, however many matching exceptions the group holds. A plain except ValueError: would not match at all, because the group itself is not a ValueError.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A request handler fetches prices, stock and reviews in an asyncio.TaskGroup. Its except TimeoutError: never runs: when the reviews service times out, the request fails with an ExceptionGroup and the prices are lost. Why, and how do you return partial results while unexpected errors still surface?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Design how a sign-up API reports every invalid field at once, including those of a nested address form, as a JSON list for the client, while a programming error such as a TypeError still produces a 500. Which exceptions do you define, and where do you catch what?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • ExceptionGroup(message, [exc, …]) raises several exceptions as one; it has .message and .exceptions, can be nested, and must hold at least one exception.
  • except* SomeError: runs once with a group of the matching exceptions; several except* clauses can run for one group, and whatever none of them matches is raised again.
  • try statements use except or except*, never both; an except* clause cannot return, break or continue, and cannot name ExceptionGroup. Since Python 3.14, except* A, B: needs no brackets.
  • split() and subgroup() filter a group by type or with a function, keep its nesting and its notes, and return None for an empty result.
  • asyncio.TaskGroup reports the failures of its tasks as a group and cancels the tasks still running; handle them with except*, because except ValueError: never matches a group.

Terms in this lesson

exception group
An exception that carries several others, made with ExceptionGroup(message, exceptions). except* handles each kind of exception inside it, and a group can hold other groups.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.