1.6. Understanding Errors and Catching Exceptions
When Python detects something wrong in your code, it will raise an exception to indicate that an error condition has occurred and it is severe enough that Python can’t continue running the rest of your code.
A very simple example of an exception occurs when we try to divide a number by zero:
>>> x = 42 / 0
Trying to run the above code will result in an error like this:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero
Let’s start with the last line: it tells us the name of the exception
(ZeroDivisionError
) and a description of why the exception was
raised (division by zero
).
The error message contains a stack trace or a synopsis of the state of the call stack when the error is detected.
Specifically, in this example, the first two lines constitute the
stack trace. Because we directly typed x = 42 / 0
into the
interpreter, these lines don’t provide a lot of information (it is
immediately apparent that the error originated in the code we just
typed).
The stack trace is much more useful when an exception is raised inside a function, as it will tell us the exact line inside that function where the exception was raised. Not just that, it will provide us with the complete sequence of function calls that led to the exception.
For example, suppose we wanted to write a simple program that prints the result of dividing an integer N by all the positive integers less than N.
def divide(a, b):
""" divide a by b """
return a / b
def print_divisions(N):
""" Print result of dividing N by integers less than N. """
for i in range(N):
d = divide(N, i)
print(f"{N} / {i} = {d}")
If we store this code in a file named exc.py
, import it into
python, and then call the print_divisions
function, we’ll see
something like this:
>>> import exc
>>> exc.print_divisions(12)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "exc.py", line 8, in print_divisions
d = divide(N, i)
File "exc.py", line 3, in divide
return a / b
ZeroDivisionError: division by zero
This stack trace is much more informative. In particular, it tells us than an exception happened after the following sequence of events:
Python ran
exc.print_divisions(12)
in the interpreter (shown in the stack trace as asFile "<stdin>", line 1, in <module>
).During the call to
print_divisions
, Python ran the lined = divide(N, i)
, located in line 8 in the fileexc.py
.During the call to
divide
, Python ran the linereturn a / b
, located in line 3 ofexc.py
. This last entry in the stack trace produced aZeroDivisionError
exception.
Notice that this trace is simply a printed version of the function call stack, which we covered in section The function call stack of the chapter that introduced functions.
There is clearly something wrong in our code, and the exception’s
stack trace can be very useful to figure out exactly what is wrong.
Frequently there is a difference between the line that raises the
exception, and the code where the error actually originates. For
example, in this case, they are not the same! While it is true that
the a / b
statement is the point at which the division by zero
occurs, there is actually nothing wrong with that particular line or
the divide
function itself. As a result, we turn our attention to
the next entry in the call stack:
File "exc.py", line 8, in print_divisions
d = divide(N, i)
This line contains a hint at the source of the problem: i
must
have taken on the value 0
at some point to have triggered the
ZeroDivisionError
error. Where does the i
get its value?
From the for
loop in print_division
:
for i in range(N):
This for
loop will iterate over the values 0, 1, 2, … N-1; we
want to avoid the value zero, so the range
should start at one:
for i in range(1, N):
Instead of changing exc.py
, we will copy the file to a new file
named exc_fixed.py
and then fix the copy. If we import the corrected
version and run exc_fixed.print_divisions(12)
, we will get:
>>> import exc_fixed
>>> exc_fixed.print_divisions(12)
12 / 1 = 12.0
12 / 2 = 6.0
12 / 3 = 4.0
12 / 4 = 3.0
12 / 5 = 2.4
12 / 6 = 2.0
12 / 7 = 1.7142857142857142
12 / 8 = 1.5
12 / 9 = 1.3333333333333333
12 / 10 = 1.2
12 / 11 = 1.0909090909090908
Now, we get the result we expect. The loop starts at 1
and no
longer triggers the divide-by-zero exception.
Making mistakes is an important part of learning to program and, it is a normal part of writing and debugging code even, for experience programmers. Learning to use the clues provided by an error message to identify the source of a bug takes time and patience. When your code raises an exception, try not to get too frustrated and try not fixate on the exact line that raises the exception. Instead, start by reading the last line of the error message to understand exactly which exception was raised and then work your way through the stack trace, starting with the line that raised the exception and working backwards through the call chain. Using the insight you gained from that process to decide where to add print statements to highight the value of crucial variables and to reason careully about the flow of control will help lead you to the source of the problem.
1.6.1. Catching exceptions
While exceptions can alert us to errors in our code, they can also be
caught and handled in a way that is consistent with the goals of the
application. In the case of our divide
function, we’ll just
return None
to indicate to the client that the value of a / b
is
not defined when b
is zero.
We can catch an exception with a try
statement, also known as a
try
.. except
block. For example:
def divide(a, b):
""" divide a by b """
try:
ret_val = a / b
except ZeroDivisionError:
# Send None back to the caller to signal
# that a / b is not defined.
ret_val = None
return ret_val
We will store this version of divide
along with the original code
for print_divisions
(shown below) in a file named exc_try.py
:
def print_divisions(N):
""" Print result of dividing N by integers less than N. """
for i in range(N):
d = divide(N, i)
print(f"{N} / {i} = {d}")
If we run this new version, we will get:
>>> import exc_try
>>> exc_try.print_divisions(12)
12 / 0 = None
12 / 1 = 12.0
12 / 2 = 6.0
12 / 3 = 4.0
12 / 4 = 3.0
12 / 5 = 2.4
12 / 6 = 2.0
12 / 7 = 1.7142857142857142
12 / 8 = 1.5
12 / 9 = 1.3333333333333333
12 / 10 = 1.2
12 / 11 = 1.0909090909090908
A try
statement allows us to “try” a piece of code, which we write
after the try:
and, if it raises an exception, run an alternate
piece of code, which can be found after the except:
. In this case,
the division in the first call to divide
will trigger the
exception and the code in the except
clause will be run and will
set ret_val
to None
. Once the code in the except
clause
is finished, the return
statement that follows the try
statement will be executed. The value of ret_val
will be
returned to print_divisions
, which, in turn, will simply print it
as the result of the division. None of the subsequent calls to
divide
in the loop will raise the exception. In these cases,
ret_val
will simply be set to the result of the division and
returned as expected.
The divide
function as written now handles division by zero
without failing. Notice, however, that it can still fail. For
example, notice what happens if we pass it non-numeric arguments:
>>> exc_try.divide("abc", "a")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "exc.py", line 4, in divide
return a / b
TypeError: unsupported operand type(s) for /: 'str' and 'str'
This usage raises a different type of exception, a TypeError
. Our
try
statement catches the ZeroDivisionError
exception, but not
the TypeError
exception. As a result, our program
stops, and the stack trace message shown above is printed,
Fortunately, we can catch multiple types of exceptions in the same
try
statement:
import sys
def divide(a, b):
try:
ret_val = a / b
except ZeroDivisionError:
# Send None back to the caller to signal
# that a/b is not defined.
ret_val = None
except TypeError as err:
# Fail: no way to move forward.
print("Type error:", err)
sys.exit(1)
return ret_val
Now when we call divide
on strings, our error message is printed
and the execution ends on the call to sys.exit(1)
:
>>> exc_try.divide("abc", "a")
Type error: unsupported operand type(s) for /: 'str' and 'str'
This example also illustrates another feature of exceptions: we can
use the keyword as
to give a name to the exception that was
caught. In this case, we use the name err
and pass it to print
along with the string "Type error:"
. Using a mechanism that we’ll
discuss in the chapter on Classes and Objects, print
extracts a
string that describes the exception that occurred from err
and prints
it.
We might not know all of the possible exceptions that can be raised by
a given piece of code when we first write it or the set of possible
exceptions might change over time (say, because a function we use has
changed and can now raise a broader set of exceptions). If we want to
make sure to deal with all possible types of exceptions, we can catch
the generic exception Exception
. This exception is best used to
handle unexpected exceptions, as in:
>>> import sys
>>> def divide(a, b):
... try:
... ret_val = a / b
... except ZeroDivisionError:
... # Send None back to the caller to signal
... # that a/b is not defined.
... ret_val = None
... except TypeError as err:
... # Fail: no way to move forward.
... print("Type error:", err)
... sys.exit(1)
... except Exception as err:
... # Fail: no way to move forward.
... print("Unexpected error:", err)
... sys.exit(1)
...
... return ret_val
...
The last except
clause will only be executed, if the code in the
try
block throws an exception other than ZeroDivisionError
or
TypeError
. You might be tempted to use a generic Exception
to
catch everything. Don’t. It is likely that your application will be
better served by handling different exceptions in different ways.
The try
statement also has an optional finally
clause that
gets run whether an exception is raised or not. This clause is useful
when there are cleanup operations that need to be performed
(closing files, closing database connections, etc.) regardless of
whether the code succeeded or failed. For example:
import sys
def divide(a,b):
""" Divide a by b and catch exceptions"""
try:
ret_val = a / b
except ZeroDivisionError:
ret_val = None
except TypeError as err:
print("Type error:", err)
sys.exit(1)
except Exception as err:
print("Unexpected Error:", err)
sys.exit(1)
finally:
print(f"divide() was called with {a} and {b}")
return ret_val
>>> divide(6, 2)
divide() was called with 6 and 2
3.0
>>> divide(6, 0)
divide() was called with 6 and 0
Before we close this chapter, let’s look at what happens when we catch
some exceptions close to the source, but leave others to be handled
higher up the call stack. Specifically, we’ll return to the example
from the start of the chapter. We’ve modified divide
to catch the
TypeError
, but not ZeroDivisionError
. Instead, we’ll handle
that error in print_divisions
.
import sys
def divide(a,b):
""" Divide a by b and catch exceptions"""
try:
ret_val = a / b
except TypeError as err:
print("Type error:", err)
sys.exit(1)
return ret_val
def print_divisions(N):
""" Print result of dividing N by integers less than N. """
for i in range(N):
try:
d = divide(N, i)
print(f"{N} / {i} = {d}")
except ZeroDivisionError:
print(f"{N} / {i} is undefined")
print_divisions(12)
12 / 0 is undefined
12 / 1 = 12.0
12 / 2 = 6.0
12 / 3 = 4.0
12 / 4 = 3.0
12 / 5 = 2.4
12 / 6 = 2.0
12 / 7 = 1.7142857142857142
12 / 8 = 1.5
12 / 9 = 1.3333333333333333
12 / 10 = 1.2
12 / 11 = 1.0909090909090908
In the first iteration of the loop in print_division
, i
will
be zero, which will cause the division operation in divide
to
raise an exception. The try
statement in divide
catches type
errors, but not divide-by-zero errors. So, Python will propagate the
error to call site in print_division
to see if the call to
divide
is nested within a try
statement that knows how to
handle divide-by-zero errors. In this case it is, and so, the
exception is handled by the except
clause in print_divisions
.
In general, an exception will be propagated up the call stack until it
is caught by an enclosing try
statement or Python runs out of
functions on the stack.
We have only skimmed the surface of exceptions in this chapter. You
now know enough to read error messages and handle simple exception
processing. We’ll return to the topic of exceptions later in the book
to look ways to catch related types of errors in one except
clause
and how to define and raise your own exceptions.
1.6.2. Practice Problems
The practice problems in this section refer to the following functions:
>>> def some_func(x):
... """ Docstring left out on purpose """
... if x < 0:
... # Will raise a TypeError if x is not a string.
... return str(x) + x
... elif x == 0:
... # Will raise a ZeroDivisionError
... return str(10 / x)
... elif x < 10:
... # Will raise an AssertionError
... assert False
...
... return "some_func does not raise an exception"
...
...
>>> def some_other_func(x):
... """ Docstring left out on purpose """
... try:
... result = some_func(x + 1)
... except TypeError:
... return "Caught TypeError in some_other_func"
... except ZeroDivisionError:
... return "Caught ZeroDivisionError in some_other_func"
... return result
...
...
>>> def yet_another_func(x):
... """ Docstring left out on purpose """
... try:
... result = some_other_func(x - 2)
... except TypeError:
... return "Caught TypeError in yet_another_func"
... except ZeroDivisionError:
... return "Caught ZeroDivisionError in yet_another_func"
... except Exception as err:
... result = f"Caught {err} in yet_another_func"
... return result
...
Problem 1
What value(s) could you pass to
some_func
to cause it to raise aTypeError
,What value(s) could you pass to
some_func
to cause it to raise aZeroDivisionError
,What value(s) could you pass to
some_func
to cause it to raise anAssertionError
, andWhat value(s) could you pass to
some_func
to cause it return a value rather than raise an exception?
Problem 2
What value(s) could you pass to
some_other_func
that will cause it to return"some_func does not raise an exception"
,What value(s) could you pass to
some_other_func
that will cause it to return"Caught TypeError in some_other_func"
, andWhat value(s) could you pass to
some_other_func
that will cause it to return"Caught ZeroDivisionError in some_other_func"
.
Problem 3
What is the result of evaluating some_other_func(5)
?
Problem 4
What is the result of evaluating the following calls:
yet_another_func(0)
,yet_another_func(1)
,yet_another_func(5)
,yet_another_func(15)
?
1.6.3. Practice Problem Solutions
Problem 1
Any call to
some_func
with a value less than zero (e.g.some_func(-1)
) will raise aTypeError
.The
some_func(0)
will raise aZeroDivisionError
.Any call with a value between one and nine (inclusive) (e.g.
some_func(5)
) will raise anAssertionError
.Any call with a value greater than or equal to ten (e.g.
some_func(20)
) will not raise an exception and will return the string'some_func does not raise an exception'
.
Problem 2
Any call to
some_other_func
with a value greater than or equal to nine (e.g.some_other_func(9)
return the string'some_func does not raise an exception'
. The call tosome_func
in the body of thetry
block ofsome_other_func
does not raise an exception and so, none of the exception handlers are executed and andsome_other_func
executes the final return statement.Any call with a value less than -1 (e.g.
some_other_func(-2)
) will return'Caught TypeError in some_other_func'
. The call tosome_func
in thetry
block will raise aTypeError
. Since the error is not caught bysome_func
it is propagated tosome_other_func
where it is caught by the exception handler. The exception handler returns the string.The call
some_other_func(-1)
will return'Caught ZeroDivisionError in some_other_func'
. The call tosome_func
in thetry
block will raise aZeroDivisionError
. Since the error is not caught bysome_func
it is propagated tosome_other_func
where it is caught by the exception handler. The exception handler returns the string.
Problem 3
>>> some_other_func(5)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 4, in some_other_func
File "<stdin>", line 11, in some_func
AssertionError
This function raises an exception because the call to some_func
raises an AssertionError
and neither some_func
nor some_other_func
catch that type of exception, so it is propagated to top-level.
Problem 4
Each call to yet_another_func
calls some_other_func
, which in
turn calls some_func
. Some of the calls to some_func
raise
exceptions, others do not.
>>> yet_another_func(0)
'Caught TypeError in some_other_func'
In this case, the call to some_func
raises a TypeError
, which
caught and handled by the TypeError
clause the try
block in some_other_func
. That
handler has a normal return statement, so the call to
some_other_func
finishes normally and the try
block in yet_another_func
finishes normally.
>>> yet_another_func(1)
'Caught ZeroDivisionError in some_other_func'
Similar to the previous example, the exception, ZeroDivisionError
, is handled by some_other_func
.
>>> yet_another_func(5)
'Caught in yet_another_func'
In this case, the call to some_func
raises an AssertionError
.
That error is not caught in either some_func
or
some_other_func
and so, it is propagated to the try
block in
yet_other_func
, where it is caught and handled by the
Exception
clause.
>>> yet_another_func(15)
'some_func does not raise an exception'
This call does not raise any exceptions.