Welcome to day 19 of the 30 Days of Python series! Today we're going to be looking at the very important topic of exception handling.
We're also going to talk a little bit about how exceptions are used in Python, and the concept of asking for forgiveness vs. asking for permission in our code.
What are exceptions?
At this point in the series, I'm sure we've all encountered our fair share of exceptions. SyntaxError
, NameError
, and TypeError
are probably starting to feel like co-workers that you see every time you sit down to code.
At the moment, encountering one of these exceptions when running our code is fatal to the application. If we try to turn user input into an integer, and the user enters "Five"
, the program is going to terminate, and we're going to get some lovely red text in the console about how "Five"
isn't a base-10 integer.
Traceback (most recent call last):
File "main.py", line 1, in <module>
int("Five")
ValueError: invalid literal for int() with base 10: 'Five'
Looking at an example like this, it's fair to assume that exceptions are just errors, and for the most part that assumption holds true. Most exceptions are indeed errors. However, there are some exceptions which don't really indicate that something went wrong. They're more like notifications that a given event occurred.
An example is StopIteration
, which is probably the most common exception being raised in your applications right now: you've just never seen it, because it never terminated your programs! More on that in a little bit.
One place we see StopIteration
is when we iterate over some iterable in a for
loop. It's used to indicate that the all of the iterable's values have been used, and it has no new values to give us. This is how Python knows when to terminate for
loops, and the same happens when we use destructuring to assign to several variables.
Using an exception as a signal like this is very common in Python, and this is something we're going to return to throughout this series.
Asking for permission vs asking for forgiveness
Let's return to the example from earlier where the we're trying to get an integer from the user, and they enter "Five"
instead of the numeral we expect.
Our code may look something like this:
number = int(input("Please enter a whole number: "))
This type of code is nothing new to us, but it's good for us to be clear about what we're discussing.
Perhaps you disagree, but I think terminating the application in this case is a bit of a strong response to the user entering an invalid value. Even if we do want to end the program, giving the user a big red error message is probably not the best way to go. It makes it sound like something is broken, rather than there being an issue with their input. After all, we never had any plans to accept numbers written as words.
Let's think about how to solve this problem with the things we know so far.
I think a sensible approach here would be to put the prompt inside a while
loop. We can then check if the user input is a valid integer, and if it is, we'll use that assign that value to a variable and break out of the loop. If it isn't valid, we'll move onto the next iteration, and the user will be prompted again.
The question is, how do we check if something is a valid integer value when we just have a string?
This is a bit tricky for us to do manually, but if you've been looking at the methods we have available for strings, you may have found the isnumeric
method. We can therefore write something like this:
while True:
user_number = input("Please enter a whole number: ")
if user_number.isnumeric():
number = int(user_number)
break
else:
print("You didn't enter a valid integer!")
If we give it a try, it seems to work. We can enter 4
or 4834854
, and it doesn't accept 3.141592
, which is good, because would cause problems for our int
conversion.
However, if you tested any negative numbers, you're going to uncover an issue. isnumeric
returns False
for negative numbers, because not all of the digits are numerals.
That's a problem, but it's not insurmountable. We can just strip off any initial -
symbol, since we know that int
can handle those. For this, we might use lstrip
, rather than normal strip
, because lstrip
only removes the character from the left side of the string.
while True:
user_number = input("Please enter a whole number: ")
if user_number.lstrip("-").isnumeric():
number = int(user_number)
break
else:
print("You didn't enter a valid integer!")
Let's try again!
-1
works no problem now, so that's great. Positive numbers still work as well, so we haven't broken anything there. However, we do have a new problem: lstrip
is a bit too good, and it will strip off many -
characters if it finds them. That's an issue for us, because while -3
is a valid number as far as int
is concerned, --3
isn't.
That means that our if statement will accept --3
as valid, but then converting it to an integer will give us an error.
If we try --3
, we get a ValueError
.
Traceback (most recent call last):
File "main.py", line 5, in <module>
number = int(user_number)
ValueError: invalid literal for int() with base 10: '--3'
At this point, I think it's starting to become clear that we're on the wrong path. Even for this very simple case, we're having to manually deal with lots of edge cases, and it can be difficult to know if we're missing something.
This kind of approach is called "asking for permission". We're checking if something can be done in advance, and then we proceed if we determine that there aren't going to be any problems. As we've seen, this approach can be very messy, and can get extremely complicated.
This is not the approach to exception handling that we take in Python. In Python, the preferred approach is to simply attempt what we think may fail, and then to recover from an exception if one occurs. This turns the problem into a much simpler one: knowing what exceptions might occur. In the case above, we only need to worry about one exception: ValueError
.
This alternative pattern is known as "asking for forgiveness", because we're attempting something that could go wrong, and then we're doing something to make amends if something does go wrong.
Let's take a look at a new piece of syntax that will allow us to use this asking for forgiveness pattern: the try
statement.
The try
statement
A try
statement can get very long and detailed, but we actually only need two parts to get going. We need a try
clause, which houses the code we expect to fail, and then usually we need at lease one except
clause that will describe what to do when a certain type of failure occurs.
Let's look at our number example again to see a try
statement in action:
while True:
try:
number = int(input("Please enter a whole number: "))
break
except ValueError:
print("You didn't enter a valid integer!")
Here we have two lines of code we want to try. We would like to take in some user input and convert it to an integer, and then we would like to break out of the while
loop if nothing goes wrong.
Our except
clause is waiting to see if any ValueError
is raised while we're running these operations in the try
clause. If a ValueError
is raised, we abandon the code in the try
clause and we perform the actions listed in this except
clause instead.
In this example, we simply print, "You didn't enter a valid integer!"
, and then we run out of code in the loop body, so a new iteration of the loop begins.
Note that we don't get an error message in the console when the ValueError
occurs.
We "handled" the exception with our except
clause, and we provided an alternative course of action. Only unhandled exceptions terminate the application, because in those cases, we haven't provided a viable alternative. Terminating the application is really Python's last resort.
Another thing to keep in mind is that we've only handled the one type of exception here. What happens if we somehow get a TypeError
instead?
Our except
clause is doing nothing at all to handle a TypeError
so we aren't providing some alternative course of action in this instance. That means a TypeError
is still going to terminate our program, and that can sometimes be exactly what we want. Sometimes there's just nothing we can do to correct a problem, and in those cases letting an exception terminate the program is perfectly acceptable.
Important
An important thing to keep in mind is that the try
block is going to stop running as soon as an exception occurs. If something goes wrong, it's as if none of the code in the try
block ever ran.
Handling multiple possible exceptions
There are two ways we can handle multiple exceptions using a single try
statement, and they have different use cases.
Let's first imagine we have two different exceptions that might occur, and we want to do different things depending on what happens. In this case we should have two except
clauses, and each except
clause should describe the course of action we want to take when that exception occurs.
As an example, let's create a function that calculates the mean average from some collection of numbers. We don't know what the user is going to pass into this function, so a few things can go wrong.
Here is what we're going to start off with:
import math
def average(numbers):
mean = math.fsum(numbers) / len(numbers)
print(mean)
I'm using fsum
here, just because the numbers are likely to be floats rather than integers in many cases.
Okay, so let's think about potential issues:
- The user may pass in an empty collection, so then we're going to get
0
returned fromlen
. That's going to lead to division by0
, which is not allowed. In this case, we get aZeroDivisionError
. - The user may pass in something which isn't a collection. This is going to give us a
TypeError
, becausefsum
expects an iterable, andlen
expects a collection. - The user may pass in a collection which contains things which aren't numbers. This is also going to be a
TypeError
.
That gives us two exceptions we need to take care of: ZeroDivisionError
and TypeError
.
If you're not sure what kind of error is going to occur, a good approach is just to try the test case and see what happens. The documentation is also quite good at describing what exceptions occur for various operations and functions.
Now that we know what can go wrong, we can write our try
statement.
import math
def average(numbers):
try:
mean = math.fsum(numbers) / len(numbers)
print(mean)
except ZeroDivisionError:
print(0)
except TypeError:
print("You provided invalid values!")
Now we're handling situations where we get ZeroDivisionError
differently from those where we get TypeError
, so we're able to provide more specific feedback on what went wrong. In the case of ZeroDivisionError
, we're not even informing the user of an issue: we've decided that the average of nothing is 0
for the purposes of our function.
If we instead want to catch both exceptions and do the same thing, we don't need two except
clauses. We can catch multiple exceptions with the same except
clause like so:
import math
def average(numbers):
try:
mean = math.fsum(numbers) / len(numbers)
print(mean)
except (ZeroDivisionError, TypeError):
print("An average cannot be calculated for the values you provided.")
Important
At some point you may run across code which reads like this:
import math
def average(numbers):
try:
mean = math.fsum(numbers) / len(numbers)
print(mean)
except:
print("An average cannot be calculated for the values you provided.")
You'll notice that we don't have any exceptions listed after the except
clause. This is called a bare except
clause, and it will catch any exceptions that occur.
While this has its uses, this is generally a really bad thing to use in your code. It opens up the possibility of catching many exceptions we didn't anticipate, and it can mask serious implementation problems.
The else
clause
In addition to the try
and except
clauses, we can also use an else
clause with our try
statements. The code under the else
clause only runs if no exceptions occur while executing the code in the try
clause.
When finding out about else
I know a lot of students think it's totally useless. Can't we just put more code in the try
block?
We can, but here are a couple of reasons why this can be a really bad idea.
- We may accidentally catch exceptions we didn't anticipate. The more code that ends up in the
try
clause, the more likely this is to happen. This may make our handling of the exception inappropriate, because we're left dealing with a situation which didn't actually occur. - It harms readability. The
try
clause expresses what we expect to fail, and theexcept
clauses express the ways that we plan to handle specific failures in that code. The more code that gets added to thetry
clause, the less clear it is what we're actually trying, and that can make the whole structure more difficult to understand.
Does that means we always need an else
clause? No.
Use your judgement. For very simple examples, it can be overkill, like in this example:
import math
def average(numbers):
try:
mean = math.fsum(numbers) / len(numbers)
except ZeroDivisionError:
print(0)
except TypeError:
print("You provided invalid values!")
else:
print(mean)
We're not likely to fall prey to any issues when printing a number, and it's clear that this is not the thing we're concerned with testing.
Just make sure you don't forget about else
when it comes to more complicated operations.
The finally
clause
In addition to else
, we have one more important clause available to us for try
statements: finally
.
finally
is very special, because it will always run.
If an unhandled exception occurs, it doesn't matter. finally
will still run its code before that exception terminates the program.
If we return from a function inside the try
statement, finally
will interrupt that return to run its own code first. You can see an example by running this code:
def finally_flex():
try:
return
finally:
print("You return when I say you can return...")
finally_flex()
This property is extremely useful for any situations where vital clean up is required after an operation. An example is when working with files. What happens if we encounter some problem while processing data in a file? We still want to close the file when we're done, and with finally
we can make sure that this happens.
The context manager we've been using for working with files actually uses something very similar to this finally
clause behind the scenes, to ensure that files are closed no matter what. It's so similar in fact that there are ways to make our own context managers using try
statements with finally
clauses.
The main use cases for finally
are generally found in more advanced code, but it's still an important tool to know about at this stage.
Exercises
1) Create a short program that prompts the user for a list of grades separated by commas. Split the string into individual grades and use a list comprehension to convert each string to an integer. You should use a try
statement to inform the user when the values they entered cannot be converted.
2) Investigate what happens when there is a return
statement in both the try
clause and finally
clause of a try
statement.
3) Imagine you have a file named data.txt
with this content:
There is some data here!
Open it for reading using Python, but make sure to use a try
block to catch an exception that arises if the file doesn't exist. Once you've verified your solution works with an actual file, delete the file and see if your try
block is able to handle it.
When files don't exist when you try to open them, the exception raised is FileNotFoundError
.
You can find our solutions to the exercises here.
Additional Resources
You can find more information about all of the built in exceptions here.