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 aZeroDivisionErrorexception.
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_functo cause it to raise aTypeError,What value(s) could you pass to
some_functo cause it to raise aZeroDivisionError,What value(s) could you pass to
some_functo cause it to raise anAssertionError, andWhat value(s) could you pass to
some_functo cause it return a value rather than raise an exception?
Problem 2
What value(s) could you pass to
some_other_functhat will cause it to return"some_func does not raise an exception",What value(s) could you pass to
some_other_functhat will cause it to return"Caught TypeError in some_other_func", andWhat value(s) could you pass to
some_other_functhat 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_funcwith 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_funcwith 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_funcin the body of thetryblock ofsome_other_funcdoes not raise an exception and so, none of the exception handlers are executed and andsome_other_funcexecutes 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_funcin thetryblock will raise aTypeError. Since the error is not caught bysome_funcit is propagated tosome_other_funcwhere 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_funcin thetryblock will raise aZeroDivisionError. Since the error is not caught bysome_funcit is propagated tosome_other_funcwhere 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.