Overview
In this reading we’ll introduce Python’s exception handling facilities, which is the primary way to deal with error situations in Python code.
Topics
-
Exception handling
-
How to recover from errors
-
The
try
/except
statement -
Exception objects
-
Raising and catching exceptions
-
Catch-all exception handlers
-
Multiple exception handlers
Disclaimer
This reading and the next are not "fun" readings. We won’t see cool new ways to accomplish neat stuff in a few lines of code. Instead, we will do two things:
-
learn how to handle errors correctly
-
understand better how Python works internally
Exception handling is a surprisingly tricky subject that often causes a lot of confusion for new programmers (and experienced ones!). The syntax is easy to learn, but understanding how to use exceptions properly is a lot harder. Some interesting design decisions will come up!
Error handling strategies
Many possible errors can occur while running a Python program:
-
division by zero
-
accessing a non-existent index of a list
-
accessing a non-existent key in a dictionary
-
trying to store a new value into a tuple
-
trying to open a file (for reading) that does not exist
-
etc.
How should errors be handled? Consider:
>>> a = 1 / 0
This will not give a legal value (Python doesn’t have infinitely large integers ). So how should this error be handled? There are many possibilities.
Silent failure
We could ignore the error and just continue execution. This is called "silent failure", and as you might guess, it’s not a very good idea. Ignoring an error might cause the program to crash, or do something even worse (like allowing a malicious user to break into a system). Silent failure is (unfortunately) common in some languages, notably C and Javascript.
Another problem with silent failure is that the user is given no indication that an error even occurred! Therefore, by definition they also don’t know why the error occurred or where in the code it occurred. (They need that information in order to fix the code.)
Halt the program
When an error occurs, we could simply halt the program. The advantage of this is that it prevents a crash later on. But there are still problems.
First of all, it’s too drastic. Some errors can be recovered from. For instance, if you try to read from a nonexistent file, if the error could somehow be detected, you could inform the user that the file wasn’t found and ask them what to do. Options might include "exit now", or "ask for another file".
Second, we don’t know why the error occurred or where it occurred, only that it occurred. If there is a bug, this is not enough information to allow us to fix the bug.
Print an error message and halt the program
We could print an informative error message before halting the program.
Advantages:
-
It prevents a crash.
-
We know why the error happened.
Problems:
-
It’s too drastic (some errors can be recovered from!)
-
We still don’t know where the error occurred.
Print an error message and a traceback and halt the program
We could print an informative error message stating why and where the error occurred and halt the program.
Advantages:
-
It prevents a crash.
-
We know why and where the error happened.
Problems:
-
It’s too drastic (some errors can be recovered from!)
This is what Python does by default. Let’s create an error situation in Python:
1>>> a = 1 / 0
2Traceback (most recent call last):
3 File "<stdin>", line 1, in <module>
4ZeroDivisionError: division by zero
There are two components to this error message:
-
an error message (line 4) states what kind of error occurred and more specifically why the error occurred in this particular case;
-
a traceback (lines 2-3) states where the error occurred. [1]
We’ll look into tracebacks in greater depth in the next reading.
Error recovery
Errors do not have to result in the termination of the entire program! Many kinds of errors can be recovered from. For instance:
-
bad input → prompt for new input
-
nonexistent files → use a different file
-
etc.
There is no general rule on how best to recover from errors; it’s just too specific to the particular situation where the error occurs. Instead, we need a general mechanism that can allow us to adopt whatever error-handling strategy might make the most sense in the context in which the error occurs. Exception handling is that general mechanism.
Code written using exception handling essentially says this:
-
Under normal circumstances, execute a chunk of code A.
-
If an error EA happens while executing code chunk A, abandon code chunk A and instead execute a chunk of code EA'.
-
Otherwise, if an error EB happens while executing code chunk B, abandon code chunk B and instead execute a chunk of code EB'.
-
etc. until all possibilities are handled.
This seems complicated but it’s actually fairly straightforward as long as it happens inside a single function. You just have to get used to the fact that errors can make the execution of code jump from one place in the code to a different place.
What happens when there is more than one function involved is the subject of the next reading.
Errors and exceptions
Errors represent "exceptional conditions",
which means that they represent a situation which is different
from the usual way code executes.
Even though the majority of exceptions represent error conditions
(file not found, division by zero,
indexing off of the end of an array, etc.),
not all exceptions represent errors.
For instance, exiting a for
loop
can be signaled by raising a StopIteration
exception
in an iterator object,
which is clearly not an error.
(We will discuss this more when we cover
user-defined iterator classes.)
Example
Let’s say we want to compute the roots of a quadratic equation.
We need to solve this equation for x
.
There is a standard solution that everyone learns
in elementary math courses:
Solve: a*x2 + b*x + c = 0 Solution 1: (-b + sqrt(b2 – 4*a*c)) / (2*a) Solution 2: (-b - sqrt(b2 – 4*a*c)) / (2*a)
where a
, b
, and c
are numerical parameters.
These solutions only work if a
is not 0
,
so there is the potential for error here.
Specifically, if a
is 0
then you will be dividing by zero,
which will result in an error.
[2]
Let’s write the Python code.
from math import sqrt def solve_quadratic_equation(a, b, c): """Solve the quadratic equation a*x**2 + b*x + c == 0 for x.""" sol1 = (-b + sqrt(b**2 - 4.0 * a * c)) / (2.0 * a) sol2 = (-b - sqrt(b**2 - 4.0 * a * c)) / (2.0 * a) return (sol1, sol2)
Problem: What should we do if a
is 0
?
This would make sol1
and sol2
uncomputable (division by zero).
We could always use an if
statement to check whether a
was 0
before computing anything else.
This is not a bad strategy, but usually a
will not be 0
.
We would rather have the code represent the typical case first,
and the exceptional cases only when the typical case fails.
So we’ll do this in a different way.
try
and except
Python provides a special kind of statement
for dealing with errors that can be recovered from:
a try
/except
statement.
We’ll show what this looks like in the context of our example,
then explain it in detail.
Note that if a
is 0
, then the equation becomes
b*x + c = 0
or:
x = -c / b
(assuming b
is nonzero).
[3]
We can rewrite the Python code as follows:
1from math import sqrt
2
3def solve_quadratic_equation(a, b, c):
4 """Solve the quadratic equation a*x**2 + b*x + c == 0 for x."""
5 try:
6 sol1 = (-b + sqrt(b**2 - 4.0*a*c)) / (2.0*a)
7 sol2 = (-b - sqrt(b**2 - 4.0*a*c)) / (2.0*a)
8 return (sol1, sol2)
9 except ZeroDivisionError:
10 # a == 0; assume b != 0
11 sol = -c / b
12 return sol
As you can see, the old body of the function is inside the try
block
on lines 6-8.
In addition to the new keyword try
,
there is another new keyword except
which is followed by the name of an exception
(here, ZeroDivisionError
)
and a block of code to be executed in the event
that that exception is raised in the code of the try
block.
Structure of a try
/except
statement
try: # ... some code which may result in an error ... except <name of the error>: # ... code to run if the error occurs ...
try
and except
say:
-
Try to execute this block of code (the
try
block) -
If a particular error occurs, stop executing the code in the
try
block and instead execute this other block of code (in theexcept
block)
try
and except
are block-structured statements
(like if
, for
, and while
).
You can have multiple statements
in a try
or except
block,
all indented the same.
It’s OK to have a try
block without an except
(though there’s usually no point in doing this).
However, you can’t have an except
block without a preceding try
block
(much like you can’t have an else
block
without a preceding if
block.)
Multiple except
blocks
You can have multiple except
blocks,
each corresponding to a different kind of error.
try: # ... some code which may result in an error ... except <error1>: # ... code to run if error1 occurs ... except <error2>: # ... code to run if error2 occurs ...
This is a fairly common pattern. We’ll have more to say about it below.
Catch-all except
blocks
You can also have a "catch-all" except
block,
which will execute if any kind of error occurs.
try: <some code which may result in an error> except: <code to run if any error occurs>
In general, though, you should avoid using catch-all except
blocks.
Usually, using a catch-all except
is very poor design,
because it’s not specific enough to the particular problem.
You should be aware of which specific exceptions
your code can raise and handle those exact exceptions.
Anything else is generally best handled elsewhere or just ignored.
If an exception is not handled, it will halt the program,
but sometimes that’s what should happen.
One of the few places where a catch-all except
block does make sense
is after several more specific except
blocks
at the top level of a program.
In this case, the catch-all except
block is just there
to make sure that the user of the program doesn’t see an
uncaught exception.
Terminology
Signaling an error is called raising an exception.
Sometimes we also say throwing an exception,
but the term "throw" is more appropriate in
languages like Java and C++ which use the throw
keyword to signal an exception.
Python uses the raise keyword for the same purpose,
so in Python it’s better to say "raising an exception".
|
Handling an error is called catching an exception.
This term also comes from Java and C++,
which use the catch keyword instead of except .
However, since we can’t say "excepting an exception",
Python programmers also say "catching an exception",
or sometimes "handling an exception".
|
Therefore, exceptions are raised in try
blocks
and caught in except
blocks.
Exceptions are objects
Exceptions are actual Python objects. They can have associated data. Often, this data is an error message that indicates more precisely what went wrong.
Exceptions in Python are instances of specific exception classes.
Having exceptions as classes is a very powerful feature,
because it lets us use Python’s inheritance mechanisms
to create exception hierarchies.
For instance, one except
block can catch
any exception which is an instance of a particular exception class
or any of its superclasses.
(We will discuss this in later readings.)
Some of Python’s built-in exception classes include:
-
ZeroDivisionError
(dividing by zero) -
IndexError
(list index out of bounds) -
ValueError
(invalid value of a function argument) -
TypeError
(invalid type of a function argument) -
FileNotFoundError
(e.g. reading a non-existent file)
try
/except
walkthrough
Let’s look in detail at what happens when an exception is handled. Here’s our example function:
def print_reciprocal(x): try: recip = 1.0 / x print(f'reciprocal of {x} is {recip}') except ZeroDivisionError: print('Division by zero!')
If we call this with x = 5.0
, then recip
gets the value 0.2
,
and no exception is ever raised. The function will print:
reciprocal of 5.0 is 0.2
and that’s it (the except
block is never executed).
If we call this with x = 0.0
, then as soon as we try to execute the line
recip = 1.0 / x
(or, more specifically, the 1.0 / x
part of the line),
a ZeroDivisionError
exception will be raised.
When this happens, the rest of the code in the try
block
(including the assignment of 1.0 / x
to recip
)
will be skipped,
and execution will jump out of the try
block,
looking for an except
block that can handle a ZeroDivisionError
.
Fortunately, there is one right after the try
block,
so execution will resume with the code
in the except ZeroDivisionError:
block,
and the program will print:
Division by zero!
At this point, the exception has been caught and "recovered from". As you can see, the meaning of "error recovery" is whatever the programmer wants it to be; it’s whatever happens after the exception is caught.
After the code in the except
block is executed,
execution will resume with whatever code is after the try
/except
block.
In this case, there isn’t any, so the function will return.
To recap:
as soon as an exception is raised in a try
block,
the program jumps out of the try
block
and looks for an except
block
that corresponds to that type of exception.
If it finds one, it executes the code in that block.
If it doesn’t... we’ll get to this later.
Multiple except
blocks walkthrough
As we mentioned above,
a try
block can be followed by multiple except
blocks.
Each except
block should correspond to a different type of exception.
We can also have a catch-all except
block at the end,
though it’s usually better to just leave it out.
Here’s a simple example of multiple except
blocks:
values = [-1, 0, 1] for i in range(5): try: r = 1.0 / values[i] print(f'reciprocal of {values[i]} is {r}') except IndexError: # when i > 2 print(f'index {i} out of range') except ZeroDivisionError: print('division by zero')
This code can raise (and catch) two kinds of exceptions:
IndexError
and ZeroDivisionError
.
i == 0
values[0]
is -1
, so computing the reciprocal
will not raise any exceptions.
Therefore, this prints:
reciprocal of -1 is -1.0
(The .0
in -1.0
is because the number is a float.)
i == 1
values[1]
is 0
, so computing the reciprocal
will raise a ZeroDivisionError
.
This will cause execution to leave the try
block
and enter the except ZeroDivisionError:
block.
Therefore, this will print:
division by zero
i == 2
This prints:
reciprocal of 1 is 1.0
You should be able to figure out why.
i == 3
values[3]
is off the end of the values
list,
so accessing it will result in an IndexError
being raised.
This will cause execution to leave the try
block
and enter the except IndexError:
block.
Therefore, this will print:
index 3 out of range
as will the i == 4
case.
When evaluating a try
/except
statement,
only one except
statement’s code can be executed.
When an exception is raised,
Python tries each except
block in order,
and executes the first one which "matches" the exception.
"Matching an exception"
usually means that the exception raised
is the same as the exception referred to in the except
line,
but with inheritance (covered in a future reading),
some exceptions can match more than just the exception that was raised.
Catch-all except
blocks, of course,
match all exceptions.
Even if there is a catch-all except
block at the end,
it’s not executed if a previous except
block’s code was executed first.
To illustrate, let’s change the previous code a bit
by adding a catch-all except
block:
values = [-1, 0, 1] for i in range(5): try: r = 1.0 / values[i] print(f'reciprocal of {values[i]} is {r}') except IndexError: # when i > 2 print(f'index {i} out of range') except ZeroDivisionError: print('division by zero') except: print('some other exception happened')
In the example cases:
-
if an
IndexError
exception occurs, only the code in theexcept IndexError:
block gets executed; -
if a
ZeroDivisionError
exception occurs, only the code in theexcept ZeroDivisionError:
block gets executed; -
if some other exception occurs, only the code in the catch-all
except:
block gets executed.
Different except
blocks are mutually exclusive!
Final example
When a file is opened for reading,
but the file does not exist,
a FileNotFoundError
exception is raised.
This is very common when e.g. a user inputs the wrong file name.
How can we handle this in our code?
Let’s assume that our files contain numbers, one per line.
We want to add them all up and print their sum.
First attempt
def sum_numbers_in_file(filename): sum_nums = 0 file = open(filename, 'r') for line in file: line = line.strip() sum_nums += int(line) file.close() return sum_nums
This will work fine as long as a file with the given name exists.
If not, a FileNotFoundError
will be raised, which will halt the program.
Can we do better?
Second attempt
def sum_numbers_in_file(filename): sum_nums = 0 found_file = False while not found_file: try: file = open(filename, 'r') found_file = True except FileNotFoundError: print('File not found!') filename = input('Enter another filename: ') for line in file: # ... (as before)
open
will raise a FileNotFoundError
if the file isn’t found.
Code in the except
block will be executed next.
The user will be prompted for a new filename,
then the code will try to open that file,
etc. until a file can be opened for reading.
Once open
succeeds, no exception will be raised.
file
will be a file object, found_file
will be set to True
,
and the while
loop will terminate.
Then the rest of the code will execute as before.
(It’s still possible that a ValueError
exception will be raised
if a line in the file hasn’t got the right kind of contents;
we’ll look at that case in the next reading.)
This approach is still pretty ugly. What if we had a very long list of filenames and wanted to sum all the numbers in all the files? If there are lots of filenames, it’s not practical to prompt the user every time a file doesn’t exist. It would be nice if we could do error handling outside of this function. This is the subject of the next reading. We will revisit this example and show you the right way to design the function so that you can handle exceptions in whichever way makes sense for the specific application you have in mind.
Next reading
We’ll talk about catching an exception outside of the function it was raised in. This will lead us to a discussion of the runtime stack, and what a traceback means.
[End of reading]