Python 3.11 just released, and we are going to take a look at some of the new features in this version. Today, we will look at Exception Groups.
Exception Groups
We tweeted about exception groups previously as a part of our EuroPython coverage. In this article we will take a deeper look at this feature.
Exception Groups are described in PEP 654. This feature allows us to group multiple exceptions together and raise the whole group of exceptions. Why would we need to group exceptions? The PEP lists out some motivations. Here are some of the common ones
- Suppose we are running many tasks concurrently (either threads or asyncio) and are waiting for them to finish. Multiple tasks fail with an exception. We want to individually catch and handle each exception. We can use exception groups here.
- Sometimes we call an API and retry on failure. After some number retries we give up. We now want to know what was the exception for each of the attempts. Exception groups allow us to group the exceptions from each of the retries so that they can be handled individually.
- Lets say we get an exception in a piece of code, and while handling that exception, we get another exception. We want to handle both exceptions elsewhere, then exception groups can be used to raise both exceptions.
Creating Exception Groups
You can create exception groups using the new ExceptionGroup
class. It takes a message and a list of exceptions.
def fn():
e = ExceptionGroup("multiple exceptions",
[FileNotFoundError("unknown filename file1.txt"),
FileNotFoundError("unknown filename file2.txt"),
KeyError("key")])
raise e
The code above takes two FileNotFoundError
and a KeyError
and groups them together into an ExceptionGroup
. We can then raise
the exception group, just like a normal exception.
When we run the code above, we get an output like this
+ Exception Group Traceback (most recent call last):
| File "exgroup.py", line 10, in <module>
| fn()
| File "exgroup.py", line 8, in fn
| raise e
| ExceptionGroup: multiple exceptions (3 sub-exceptions)
+-+---------------- 1 ----------------
| FileNotFoundError: unknown filename file1.txt
+---------------- 2 ----------------
| FileNotFoundError: unknown filename file2.txt
+---------------- 3 ----------------
| KeyError: 'key'
+------------------------------------
The traceback here shows all the exceptions that are a part of the group.
Handling Exception Groups
Now that we have seen how to raise
an exception group, let us take a look at how to handle such exceptions.
Normally, we use try ... except
to handle exceptions. You can certainly do this
try:
fn()
except ExceptionGroup as e:
print(e.exceptions)
The exceptions
attribute here contains all the exceptions that are a part of the group. The problem with this is that it makes it difficult to handle the individual exceptions within the group.
For this, Python 3.11 has a new except*
syntax. except*
can match exceptions that are within an ExceptionGroup
try:
fn()
except* FileNotFoundError as eg:
print("File Not Found Errors:")
for e in eg.exceptions:
print(e)
except* KeyError as eg:
print("Key Errors:")
for e in eg.exceptions:
print(e)
Here we use except*
to directly match the FileNotFoundError
and the KeyError
which are within the ExceptionGroup
. except*
filters the ExceptionGroup
and selects those exceptions that match the type specified.
Key differences with try ... except
Let us look at a few key points from this snippet
try:
fn()
except* FileNotFoundError as eg:
print("File Not Found Errors:")
for e in eg.exceptions:
print(e)
except* KeyError as eg:
print("Key Errors:")
for e in eg.exceptions:
print(e)
First, when we use the as
syntax to assign to a variable, then we get an ExceptionGroup
object, not a plain exception. In the code above, eg
is an ExceptionGroup
. For the first except*
block, eg
will contain all the FileNotFoundError
s from the original exception group. In the second except*
, eg
will contain all the KeyError
s.
Second, a matching except*
block gets executed at most once. In the above code, even if there is more than one FileNotFoundError
, the except* FileNotFoundError
block will run only once. All the FileNotFoundError
s will be contained in eg
and you need to handle all of them in the except*
block.
Another important difference between except
and except*
is that you can match more than one exception block with except*
. In the snippet above, the code in except* FileNotFoundError
and except* KeyError
will both be executed because the exception group contains both types of exceptions.
An example of ExceptionGroup
Now that we have seen how to use ExceptionGroup
, let us take a look at it in action. In the code below, we use asyncio
to read two files. (We use TaskGroup
for this, another new feature in python 3.11. More on TaskGroup
coming in the next article).
import asyncio
async def read_file(filename):
with open(filename) as f:
data = f.read()
return data
async def main():
try:
async with asyncio.TaskGroup() as g:
g.create_task(read_file("unknown1.txt"))
g.create_task(read_file("unknown2.txt"))
print("All done")
except* FileNotFoundError as eg:
for e in eg.exceptions:
print(e)
asyncio.run(main())
We call read_file
in lines 12 and 13 to read two files.
Neither of the files are existing, so both the tasks will give FileNotFoundError
. TaskGroup
will wrap both the failures in an ExceptionGroup
and we should use except*
to handle both the errors.
When to use ExceptionGroup?
Some of the newer additions to Python, like the TaskGroup
that we saw above now use ExceptionGroup
. In the future, we will see exception groups being used in many more places. However, keep in mind that you will probably be still using try ... except
much more, and except*
will be for those specific places where you really, really need exception groups. Most of the time, the library or framework will use exception groups, much like TaskGroup
above, rather than regular code.
Which brings us to the question: When should you use exception groups in your own code?
Here, I would refer back to the motivating examples in the PEP. There are certain situations where multiple exceptions need to be raised and the all the exceptions need to be handled. Exception groups are perfect for this.
Most of the time though, good old single exceptions and try ... except
should be just fine.
Some Advanced Cases
Now that we have understood the basic usage, let us look at some more advanced cases. You will not need these most of the time, but read on if you want to understand how these cases work
Nested Exception Groups
Exception groups can be used anywhere we use regular exceptions, which means you can have one exception group as a part of another exception group
def fn():
e = ExceptionGroup("multiple exceptions",
[ExceptionGroup("file not found",
[FileNotFoundError("unknown filename file1.txt"),
FileNotFoundError("unknown filename file2.txt")]),
KeyError("missing key")])
raise e
Keep in mind that when using except* ... as eg
to match exceptions, then eg
will maintain the structure of the original exception group.
try:
fn()
except* FileNotFoundError as eg:
# eg will be
# ExceptionGroup("multiple exceptions",
# [ExceptionGroup("file not found",
# [FileNotFoundError("unknown file1"),
# FileNotFoundError("unknown file2")])])
Note that any empty branches of the tree will not be present.
Unhandled Exceptions
In the case that any exceptions are unhandled, then an ExceptionGroup
containing the unhandled exceptions will be propogated up the call chain. Lets say we only handled FileNotFoundError
try:
fn()
except* FileNotFoundError as eg:
...
The exception group containing just the unhandled KeyError
will be propagated and can be handled elsewhere. As before, structure is maintained, so if the KeyError
is nested somewhere, it will remain in the same position when propogated. Also, any empty branches which were fully handled will be removed from the exception group.
The subgroup and split methods
The ExceptionGroup
class contains two methods: subgroup
and split
which can be used to manually work with the exception tree.
subgroup
is used to select matching exceptions from the tree. You can pass a predicate function to subgroup
and any exceptions that match the predicate will be selected. (predicate function is one that returns True
or False
)
This code will select the exceptions that have "file1" in the message
e = ExceptionGroup("multiple exceptions",
[ExceptionGroup("file not found",
[FileNotFoundError("unknown filename file1.txt"),
FileNotFoundError("unknown filename file2.txt")]),
KeyError("missing key")])
eg = e.subgroup(lambda ex: "file1" in str(ex))
# eg will be
# ExceptionGroup('multiple exceptions',
# [ExceptionGroup('file not found',
# [FileNotFoundError('unknown filename file1.txt')])])
As before, the structure of the tree is maintained and empty branches are removed.
split
is similar to subgroup
, but it returns two trees: one containing the matches and another containing the remaining nodes.
match,rest = e.split(lambda ex: "file1" in str(ex))
# match will be
# ExceptionGroup('multiple exceptions',
# [ExceptionGroup('file not found',
# [FileNotFoundError('unknown filename file1.txt')])])
# rest will be
# ExceptionGroup('multiple exceptions',
# [ExceptionGroup('file not found',
# [FileNotFoundError('unknown filename file2.txt')]),
# KeyError('missing key')])
Python 3.11 internally uses split
to decide which part of the exception group tree is matched in an except*
and which parts should continue propagating.
Most of the time you will use except*
instead of subgroup
or split
, but these two methods are available for the rare case when you need to select based on something other than type or want to manually process the exception group.
Did you like this article?
If you liked this article, consider subscribing to this site. Subscribing is free.
Why subscribe? Here are three reasons:
- You will get every new article as an email in your inbox, so you never miss an article
- You will be able to comment on all the posts, ask questions, etc
- Once in a while, I will be posting conference talk slides, longer form articles (such as this one), and other content as subscriber-only