Skip to content
aviral gupta

// I1.4 · ~35 min · Intermediate

Exception groups and notes

After this lesson you can collect several errors and raise them together, handle each kind with except*, and attach notes that say where an error happened.

Lesson 4 of 5 in I1 Errors in depth

You will be able to

  • Collect errors in a loop, raise them as an ExceptionGroup, and read its traceback
  • Handle parts of a group with except*, predicting which clauses run and what is left over
  • Add context to an exception with add_note, and find the notes in __notes__ and the traceback
  1. Warm-up · Activity 1 of 7

    Warm-up from the last lesson: the try block raises an exception that no except clause matches. What still happens before it reaches the caller? Pick all that apply.

    Select all that apply.

  2. Predict · Activity 2 of 7

    Predict before you read on: this raises two errors together. Its traceback has one line that describes the group itself. Which one?

    errors = [ValueError("age must be a number"), KeyError("email")]
    raise ExceptionGroup("2 records failed", errors)
  3. Practice · Activity 3 of 7

    Fill in the class that raises all the errors in the list together.

    raise ____("3 rows failed", errors)
    raise ("3 rows failed", errors)
  4. Practice · Activity 4 of 7

    One group, two except* clauses. What does this print?

    try:
        raise ExceptionGroup("batch", [ValueError("a"), KeyError("b"), ValueError("c")])
    except* ValueError as eg:
        print("values:", len(eg.exceptions), end="; ")
    except* KeyError as eg:
        print("keys:", len(eg.exceptions), end="; ")
  5. Practice · Activity 5 of 7

    eg = ExceptionGroup("batch", [ValueError("a"), KeyError("b")]). Match each expression to its value.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. Only the ValueError has an except* clause. What does this print?

    try:
        try:
            raise ExceptionGroup("batch", [ValueError("a"), KeyError("b")])
        except* ValueError:
            print("handled the ValueError", end=" | ")
    except ExceptionGroup as eg:
        print("left over:", eg.exceptions)
  7. Apply · Activity 7 of 7

    Mini-task. Write check_prices(prices), where prices maps a name to a text such as "2.5". Convert each with float and reject negative prices. Do not stop at the first problem: add a note naming the item to each error, collect them, and raise one ExceptionGroup at the end. Handle it with except* ValueError, printing each message and its note.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Checking a batch of sign-ups

check_all checks every sign-up and does not stop at the first bad one. Each error gets a note with its record number and goes into a list, and at the end one ExceptionGroup carries them all. The caller handles bad values and missing fields in separate except* clauses, and prints each error with its note.

main.py

def check(record: dict[str, str]) -> None:
    if "email" not in record:
        raise KeyError("email")
    if not record["age"].isdigit():
        raise ValueError(f"age must be a number, got {record['age']!r}")


def check_all(records: list[dict[str, str]]) -> None:
    errors: list[Exception] = []
    for number, record in enumerate(records, start=1):
        try:
            check(record)
        except (ValueError, KeyError) as err:
            err.add_note(f"in record {number}")
            errors.append(err)
    if errors:
        raise ExceptionGroup(f"{len(errors)} of {len(records)} records failed", errors)


records = [
    {"age": "34", "email": "ada@example.org"},
    {"age": "x", "email": "bob@example.org"},
    {"age": "51"},
    {"age": "-3", "email": "eve@example.org"},
]

try:
    check_all(records)
except* ValueError as bad_values:
    print(bad_values.message)
    for err in bad_values.exceptions:
        print("bad value:", err, "|", err.__notes__[0])
except* KeyError as missing:
    for key_err in missing.exceptions:
        print("missing field:", key_err, "|", key_err.__notes__[0])

Run it with

python main.py

Output

3 of 4 records failed
bad value: age must be a number, got 'x' | in record 2
bad value: age must be a number, got '-3' | in record 4
missing field: 'email' | in record 3
  • The group’s message is kept in the smaller group each except* clause receives.
  • Both ValueErrors arrive together in one run of the first except* clause.
  • After the ValueErrors are handled, the KeyError is left, so the second clause runs too.
  • str(err) is only the message; the note is read from err.__notes__.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

Collect instead of stopping

parse_all(texts) turns a list of texts into ints, but it stops at the first bad one. Make it check every text: add the note "item <n>" to each ValueError, counting from 1, collect them, and after the loop raise ExceptionGroup("some items are not numbers", errors). Return the list of ints when all texts are fine.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Loop with enumerate(texts, start=1), and put int(text) in a try inside the loop.

  2. Hint 2

    In except ValueError as err:, call err.add_note(f"item {n}") and append err to a list of errors.

  3. Hint 3

    After the loop: if errors: raise ExceptionGroup("some items are not numbers", errors). Otherwise return the numbers.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

def parse_all(texts: list[str]) -> list[int]:
    numbers: list[int] = []
    errors: list[ValueError] = []
    for n, text in enumerate(texts, start=1):
        try:
            numbers.append(int(text))
        except ValueError as err:
            err.add_note(f"item {n}")
            errors.append(err)
    if errors:
        raise ExceptionGroup("some items are not numbers", errors)
    return numbers
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

def parse_all(texts: list[str]) -> list[int]:
    return [int(text) for text in texts]

test_main.py

from main import parse_all


def test_all_good():
    """Good texts come back as ints"""
    got = parse_all(["1", "22", "-3"])
    assert got == [1, 22, -3], f"parse_all returned {got!r}"


def catch(texts):
    try:
        parse_all(texts)
    except ExceptionGroup as eg:
        return eg
    except ValueError:
        assert False, "parse_all stopped at the first ValueError; collect them into an ExceptionGroup"
    assert False, "parse_all returned normally for bad input"


def test_group():
    """Two bad texts give one group with both errors"""
    eg = catch(["1", "x", "3", "y"])
    assert eg.message == "some items are not numbers", f"the group message is {eg.message!r}"
    assert len(eg.exceptions) == 2, f"the group holds {len(eg.exceptions)} exceptions, expected 2"
    assert all(isinstance(e, ValueError) for e in eg.exceptions), "every exception in the group should be a ValueError"


def test_notes():
    """Each error has a note with its position"""
    eg = catch(["1", "x", "3", "y"])
    notes = [getattr(e, "__notes__", []) for e in eg.exceptions]
    assert notes == [["item 2"], ["item 4"]], f"the notes are {notes!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

Sort out the group

check_all is done: it raises an ExceptionGroup of ValueErrors and KeyErrors, each with a note. Write summarize(records), which returns a list of lines: "bad value: <message> (<note>)" for each ValueError, then "missing field: <key> (<note>)" for each KeyError, using except*. If check_all raises nothing, return ["all valid"]. Let any other error through.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Start with an empty list lines, then try: check_all(records) with two except* clauses: except* ValueError as bad_values: and except* KeyError as missing:.

  2. Hint 2

    In each clause, loop over the group’s exceptions and append an f-string with the error and its __notes__[0]. Use new names in the second clause, or mypy complains. return is not allowed inside except*.

  3. Hint 3

    After the try statement, return lines or ["all valid"]: an empty list is false.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

def check(record: dict[str, str]) -> None:
    if "email" not in record:
        raise KeyError("email")
    if not record["age"].isdigit():
        raise ValueError(f"age must be a number, got {record['age']!r}")


def check_all(records: list[dict[str, str]]) -> None:
    errors: list[Exception] = []
    for number, record in enumerate(records, start=1):
        try:
            check(record)
        except (ValueError, KeyError) as err:
            err.add_note(f"in record {number}")
            errors.append(err)
    if errors:
        raise ExceptionGroup(f"{len(errors)} records failed", errors)


def summarize(records: list[dict[str, str]]) -> list[str]:
    lines: list[str] = []
    try:
        check_all(records)
    except* ValueError as bad_values:
        for err in bad_values.exceptions:
            lines.append(f"bad value: {err} ({err.__notes__[0]})")
    except* KeyError as missing:
        for key_err in missing.exceptions:
            lines.append(f"missing field: {key_err} ({key_err.__notes__[0]})")
    return lines or ["all valid"]
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

def check(record: dict[str, str]) -> None:
    if "email" not in record:
        raise KeyError("email")
    if not record["age"].isdigit():
        raise ValueError(f"age must be a number, got {record['age']!r}")


def check_all(records: list[dict[str, str]]) -> None:
    errors: list[Exception] = []
    for number, record in enumerate(records, start=1):
        try:
            check(record)
        except (ValueError, KeyError) as err:
            err.add_note(f"in record {number}")
            errors.append(err)
    if errors:
        raise ExceptionGroup(f"{len(errors)} records failed", errors)


def summarize(records: list[dict[str, str]]) -> list[str]:
    check_all(records)
    return ["all valid"]

test_main.py

from main import summarize


def test_all_valid():
    """Valid records give all valid"""
    got = summarize([{"age": "34", "email": "ada@example.org"}])
    assert got == ["all valid"], f"summarize returned {got!r}"


def test_both_kinds():
    """Bad values come first, then missing fields, each with its note"""
    records = [{"age": "x", "email": "a@b"}, {"age": "3"}, {"age": "-3", "email": "c@d"}]
    got = summarize(records)
    want = [
        "bad value: age must be a number, got 'x' (in record 1)",
        "bad value: age must be a number, got '-3' (in record 3)",
        "missing field: 'email' (in record 2)",
    ]
    assert got == want, f"summarize returned {got!r}"


def test_only_missing():
    """A group with only KeyErrors is handled too"""
    got = summarize([{"age": "1"}, {"age": "2"}])
    assert got == ["missing field: 'email' (in record 1)", "missing field: 'email' (in record 2)"], f"summarize returned {got!r}"


def test_other_errors_pass():
    """Other errors are not caught"""
    try:
        summarize([{"age": 5, "email": "a@b"}])  # type: ignore[dict-item]
    except AttributeError:
        pass
    else:
        assert False, "summarize swallowed an AttributeError"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Mixing except and except*

try:
    raise ExceptionGroup("batch", [ValueError("a")])
except* ValueError:
    print("values")
except KeyError:
    print("keys")

What Python prints

SyntaxError: cannot have both 'except' and 'except*' on the same 'try'

Why, and the fix

A try statement uses one kind of clause. Inside a group, KeyError is also caught with except* KeyError:. To catch the whole group at once instead, use a plain except ExceptionGroup: in a try statement of its own.

except* with ExceptionGroup

try:
    raise ExceptionGroup("batch", [ValueError("a")])
except* ExceptionGroup:
    print("caught")

What Python prints

TypeError: catching ExceptionGroup with except* is not allowed. Use except instead.

Why, and the fix

except* looks at the exceptions inside a group, so naming the group type there has no clear meaning, and Python refuses it when the clause is reached. To catch a group as a whole, write except ExceptionGroup as eg:.

return inside except*

def load():
    try:
        raise ExceptionGroup("batch", [ValueError("a")])
    except* ValueError:
        return "handled"

What Python prints

SyntaxError: 'break', 'continue' and 'return' cannot appear in an except* block

Why, and the fix

Several except* clauses may still have to run after this one, so leaving early is not allowed. Store the result in a variable inside the clause and return it after the try statement.

A note that is not a string

err = ValueError("bad age")
err.add_note(42)

What Python prints

TypeError: add_note() argument must be str, not int

Why, and the fix

Notes are text shown in the traceback, so add_note accepts only a string. Build one with an f-string: err.add_note(f"in record {42}").

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

ExceptionGroup: several errors at once

Checking 100 records, you often want every problem, not just the first. Catch each error in the loop, append it to a list, and after the loop raise ExceptionGroup("3 records failed", errors). The list must hold exception objects, not classes. A group is an exception itself, so except Exception catches it whole; eg.message is its message and eg.exceptions a tuple of the errors. Its traceback starts with + Exception Group Traceback, names the group as ExceptionGroup: 3 records failed (3 sub-exceptions), and then shows each error in a numbered box.

except*: handle each kind

except* ValueError as eg: picks the ValueErrors out of a group. Its body runs once, and eg is a smaller group holding just those; loop over eg.exceptions to see them. Unlike except, several except* clauses can run for one group, each taking its own kind. Whatever no clause matched is raised again at the end, as a group if it came as one. A plain exception that matches except* arrives wrapped in a group with an empty message. One try uses either except or except*, never both, and return, break and continue are not allowed inside except*.

Notes: context added later

An exception’s message is fixed when it is created, often deep in code that does not know which record or file it is working on. The code that catches it does, so it can call err.add_note("in record 3") and then collect or re-raise the error. Notes appear in the traceback, one per line after the message, in the order they were added. They are stored in err.__notes__, a list created by the first add_note. str(err) is still just the message, and a note must be a string.

Sources

Last reviewed September 29, 2026