Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate Python 3.11+ ~27 min· topic 3 of 12

Topic 7.3

Exception Groups and except*

In one line

An ExceptionGroup bundles several unrelated errors into one exception, and except* handles the members of a group by type. They exist because concurrent code (like asyncio.TaskGroup) can fail in many places at once.

Think of it like this

A school bus with a flat tyre, an empty fuel tank and a broken door. A report that says only "the bus has a problem" hides two of the three. A good report lists all three problems together in one note, and then each repair person takes the problem they know how to fix. An exception group is that single note: one exception object that carries a list of other exceptions.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

ExceptionGroup
An exception that holds a list of other exceptions. Added in Python 3.11.
except*
A handler that picks out the members of a group by type and handles just those.
Sub-exception
One of the exceptions inside a group. They are listed in the .exceptions attribute.
split / subgroup
Methods that divide a group by type, or extract the part that matches, without raising anything.
TaskGroup
An asyncio tool that runs tasks together and raises an ExceptionGroup if some of them fail.
Concurrent
Several jobs in progress during the same period of time, so more than one can fail.

Step by step

01Build and read a group

The first argument is a message for the whole group. The second is a list of exceptions. Printing the group shows the message and the count. The members are available as a tuple (a fixed list, Topic 4.3) in .exceptions.

main.pywhole filepython
eg = ExceptionGroup("batch failed", [
    ValueError("bad price"),
    TypeError("name must be text"),
    ValueError("bad quantity"),
])
print(eg)
print(len(eg.exceptions))

02Handle by type with except*

This continues from the group eg built in the previous step. Each except* takes the members of its type. In the code below the handler for ValueError receives a group with two members, and the handler for TypeError receives a group with one. Both run, one after the other, because both found members. Nothing is left, so no error escapes. ([str(e) for e in group.exceptions] is a list comprehension, Topic 4.7: it turns each error into its message text.)

main.pywhole filepython
try:
    raise eg
except* ValueError as group:
    print("value problems:", [str(e) for e in group.exceptions])
except* TypeError as group:
    print("type problems:", [str(e) for e in group.exceptions])
Handle by type with except*diagram
Rendering diagram…

03What is not handled is raised again

If a member has no matching except*, it stays in a group that is raised after the handlers finish. The caller then sees an ExceptionGroup with only the leftover members. Nothing is swallowed.

main.pywhole filepython
try:
    try:
        raise ExceptionGroup("batch", [ValueError("a"), KeyError("b")])
    except* ValueError:
        print("handled the ValueError")
except ExceptionGroup as rest:
    print("left over:", [type(e).__name__ for e in rest.exceptions])

04Raising one group from a loop

Collect errors in a list while looping, then raise a single group at the end if the list is not empty. This reports every problem in one run instead of one at a time. enumerate(rows, start=1) counts the rows from 1, so the note can say which row failed (Topic 2.6 covers enumerate).

main.pywhole filepython
def check_all(rows):
    problems = []
    for number, row in enumerate(rows, start=1):
        try:
            int(row)
        except ValueError as e:
            e.add_note(f"row {number}")
            problems.append(e)
    if problems:
        raise ExceptionGroup("invalid rows", problems)

05split and subgroup

eg.split(ValueError) returns a pair: a group of the matching members and a group of the rest (either can be None). eg.subgroup(ValueError) returns only the matching part. They are useful for logging or inspecting a group without using except*.

main.pywhole filepython
matching, rest = eg.split(ValueError)
print(len(matching.exceptions), len(rest.exceptions))
print(eg.subgroup(KeyError))   # None: no KeyError inside

06Where groups come from: TaskGroup

In asyncio, a TaskGroup starts tasks that run side by side. If one fails, the others are cancelled, and when all have finished the block raises an ExceptionGroup with the failures. You will learn async and await in Topic 12.6; for now, see that the way of catching the failures is exactly except*.

main.pywhole filepython
import asyncio

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(job("a", fail=True))
            tg.create_task(job("b", fail=False))
    except* ValueError as group:
        print("failed:", len(group.exceptions))

asyncio.run(main())

Try it yourself

  1. 1

    Add a handler

    In the second example add except* KeyError as group: inside the inner try. What does the outer except ExceptionGroup print now? (Nothing, because nothing is left over, so the outer handler does not run.)

  2. 2

    Count the survivors

    In the split example, change eg.split(ValueError) to eg.split((ValueError, TypeError)). What are matching and rest now? Check whether rest is None.

  3. 3

    Make a task succeed

    In the TaskGroup example change the True for job c to False and run it. How many errors appear? Even with one failure you still need except*.

Code & diagrams

Handle a group with two except* clauses Python 3.11+ New tab

Both handlers run, because each finds members of its own type.

Sign in to run this example in your browser.

Expected output

batch failed (3 sub-exceptions)
value problems: ['bad price', 'bad quantity']
type problems: ['name must be text']
after the try statement
Leftover members are raised again Python 3.11+ New tab

The KeyError had no handler, so it comes out in a new group. Add an except* KeyError to handle it too.

Sign in to run this example in your browser.

Expected output

handled: ["ValueError('a')"]
left over: ["KeyError('b')"]
Report every bad row, not just the first Python 3.11+ New tab

Errors are collected while the loop continues, then raised as one group at the end.

Sign in to run this example in your browser.

Expected output

invalid rows (3 sub-exceptions)
 - invalid literal for int() with base 10: 'x' | row 2
 - invalid literal for int() with base 10: '' | row 4
 - invalid literal for int() with base 10: '9z' | row 5
split and subgroup Python 3.11+ New tab
Sign in to run this example in your browser.

Expected output

matching: ['a', 'c']
rest: ['b']
subgroup(KeyError): None
TaskGroup raises an ExceptionGroup Python 3.11+ New tab

Two tasks fail at about the same time. The group holds both. asyncio is covered in Phase 12.

Sign in to run this example in your browser.

Expected output

caught: job a failed
caught: job c failed

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Mix except and except*

Put a plain except and an except* in the same try statement.

main.pywhole filepython
try:
    pass
except ValueError:
    pass
except* TypeError:
    pass
terminal
$ python main.py
── what you'll see ──
File "main.py", line 5
except* TypeError:
^^^^^^^
SyntaxError: cannot have both 'except' and 'except*' on the same 'try'

Break #2

An empty group

Create an ExceptionGroup with no members.

main.pywhole filepython
errors = []
raise ExceptionGroup("nothing wrong", errors)
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 2, in <module>
raise ExceptionGroup("nothing wrong", errors)
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: second argument (exceptions) must be a non-empty sequence

Myth vs fact

Myth

except ValueError will catch a ValueError inside an ExceptionGroup.

Fact

It will not. The thing raised is the group, which is not a ValueError. You need except* ValueError (or catch ExceptionGroup and inspect it).

Myth

Exception groups are only for experts and only for async code.

Fact

They are a plain tool: use them whenever you want to report several independent failures together. asyncio.TaskGroup is the best-known producer, not the only one.

Myth

Once one except* matches, the rest are skipped, like ordinary except.

Fact

Every except* that finds matching members runs. Each takes its share of the group.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    except* handlers receive a new group made by split. The new group keeps the original message, traceback and __cause__/__context__ information, so the chain is not lost. Re-raising inside a handler with a bare raise re-raises only that handler's part.

  • ▸

    If a handler raises a new exception, it is combined with the unhandled rest into another group, so errors in error handling are not lost either.

  • ▸

    Libraries that fan out work (HTTP clients, scrapers, batch embedders) increasingly expose groups. Write the top-level of such code as try: ... except* SomeError and log each member, so a single bad URL does not hide nine other failures.

  • ▸

    except* also accepts a plain exception that is not inside any group: raise ValueError("solo") caught by except* ValueError as eg gives eg as an ExceptionGroup with one member. That is why code that must handle both shapes (a library that sometimes raises a bare error, sometimes a group) can use except* alone. A bare except*: with no class is a SyntaxError, and split keeps nested group structure, so a ValueError inside an inner group stays inside a smaller inner group.

  • ▸

    split and subgroup also accept a function instead of a class, for example eg.subgroup(lambda e: getattr(e, "status", 0) >= 500), which lets you separate retryable failures from permanent ones in a batch of HTTP errors. If you want to build a group of your own subclass that keeps its extra fields through split, override derive().

  • ▸

    BaseExceptionGroup can carry KeyboardInterrupt-style exceptions too. ExceptionGroup can hold only Exception subclasses, and Python picks the right class automatically when you call BaseExceptionGroup(...).

Remember this

  1. 1

    Until now one try handled one error at a time, because code ran one step at a time. Concurrent code (many tasks running side by side, Phase 12) can fail in several places at once. Throwing away all but one error would lose information. Python 3.11 added ExceptionGroup for this: ExceptionGroup("message", [error1, error2, ...]).

  2. 2

    The group is itself an exception. Its members are in eg.exceptions (here eg is just a variable name holding the group). They may be of different types, and may even be other groups. Recall from Topic 4.1: [a, b] is a list, so the second argument is simply a list of exception objects.

  3. 3

    To handle a group you use **except*** (read it as "except star") instead of except. except* ValueError as eg: picks the ValueError members out of the group. It runs the handler with a new smaller group (still an ExceptionGroup) holding only those. Members of other types stay behind. You can write several except* clauses. Unlike normal except, all that match will run, each taking its own share.

  4. 4

    Whatever is left after every except* has taken its members is raised again as a group, so nothing is lost silently. If every member was handled, the try ends normally. This is the key difference from except. A plain except ValueError only matches if the exception itself is a ValueError. An ExceptionGroup is not one, so a plain except cannot look inside a group.

  5. 5

    You can also write your own code that collects errors in a loop and raises one ExceptionGroup at the end. This is a good fit for checking a form or a batch of files. Instead of stopping at the first mistake, you report all of them. Two helper methods let you look inside a group without catching it. eg.split(ValueError) returns two groups (matching and rest). eg.subgroup(ValueError) returns only the matching part, or None (Python's "no value", Topic 1.4).

  6. 6

    The most common place you will meet groups is asyncio.TaskGroup (Topic 12.7). A task is one job that runs side by side with others. If several tasks inside the group fail, the async with block raises one ExceptionGroup holding all the failures. Even one failure arrives wrapped in a group, so code around a TaskGroup normally uses except*.

  7. 7

    Rules to remember. You cannot mix except and except* in the same try. You cannot use break, continue or return inside an except* block. ExceptionGroup needs a non-empty list of exceptions. There is also BaseExceptionGroup for groups that may contain BaseException members. In AI code the pattern is natural: fire 50 model calls at once, let the ones that fail raise, and report all of the failures together rather than only the first.

Explain it without notes

01

What problem does ExceptionGroup solve, and why was it added in Python 3.11?

02

How does except* differ from except? What happens to members that no handler matches?

03

Why does async with asyncio.TaskGroup() raise an ExceptionGroup even when only one task failed?

04

When would you raise your own ExceptionGroup?

Practice

01

Write check_prices(prices) that tests each value is a number greater than zero and raises one ExceptionGroup listing every bad value (ValueError for the wrong number, TypeError for text).

02

Catch the group from your first answer using two except* clauses and print how many of each kind there were.

03

Given eg = ExceptionGroup("x", [ValueError(1), OSError(2)]), print the class names of the members that are not ValueError, without using except*.

Trade-offs

  • ↔

    One report versus simple handling. Groups give complete information, but code that handles them is more involved than a single except. Use them when losing errors would hurt, not for every function.

  • ↔

    Collecting errors versus failing fast. Collecting shows all problems in one run (good for validation). Failing fast stops wasted work and is simpler when later steps depend on earlier ones.

Done when you can

  • Done when you can create an ExceptionGroup and read its .exceptions.

  • Done when you can handle a group with two except* clauses and explain what is raised again.

  • Done when you can explain why a plain except ValueError does not see inside a group.

  • Done when you can use split and subgroup to inspect a group.

  • Done when you can say where groups come from in asyncio.TaskGroup.