Learning (not) to Handle Exceptions

Learning (not) to Handle Exceptions

Learn how to deal with exceptions in Python
Aquiles Carattino 2018-06-04 Exceptions Errors Try Except Catch Handling

When you develop code, it is almost impossible not to run into an error. Some problems are going to arise as soon as you start your program, for example, if you forgot to close a parenthesis, or forgot the : after an if-statement. However, errors at runtime are also very frequent and harder to deal with. In this article, you are going to learn how to handle exceptions, i.e. how to avoid program crashes when you can anticipate that an error may appear.

We are going to cover from the basics of error handling to defining your own exceptions. You will learn why sometimes it is better not to catch exceptions and how to develop a pattern that can be useful for future users of your code. Exceptions are a crucial part of any code, and dealing with them elegantly can improve a lot the value of your code.

All the code of this article is available on Github.

Try ... Except

Imagine that you are reading data from a file. You can simply write something like this:

f = open('my_file.dat')
data = f.readfile()
print('Data loaded')

If you run the code, you will see a message like the following:

FileNotFoundError: [Errno 2] No such file or directory: 'my_file.dat'

When an error is raised, the code that follows will not be executed. That is why you don't see the statement Data loaded on the console. This is a somewhat innocent problem but imagine that you are communicating with a device. If there is an error in your program, you won't have the chance to close the communication with the device, or you won't be able to close a shutter to prevent damages to the detectors, etc.

Dealing with this kind of errors is normally done within a try / except block. This means that if there is an error inside the try, the block except will be executed. In the example above, we can do the following:

try:
    f = open('my_file.dat')
    f.readfile()
    print('Loaded data')
except:
    print('Data not loaded')

If you run the code, you will see a nice message being printed to screen saying Data not loaded. This is great! Now our program is not crashing and we can close the shutter, or stop the communication with our device. However, we don't know the reason why the data was not loaded.

Before continuing, create an empty file called my_file.dat, and run the script again. You will see that data is not being loaded, regardless whether the file exists or not. With this trivial example, you are seeing the risks around unspecific except blocks. If we make a simpler script:

f = open('my_file.dat')
f.readfile()
print('Loaded data')

The output will be:

AttributeError: '_io.TextIOWrapper' object has no attribute 'readfile'

Which is telling us that the problem is the method that we tried to use, readfile doesn't exist. When you use a plain try/except block, you are sure you are handling all possible exceptions, but you have no way of knowing what actually went wrong. In simple cases like the one above, you have only two lines of code to explore. However, if you are building a package or a function, some errors will propagate downstream, and you don't know how they are going to affect the rest of the program.

For you to have an idea of the importance of correct handling of errors, I will tell you what I have witnessed using a program that ships with a very sophisticated lab instrument. The program that controls the Nano Sight has a very nice user interface. However, when you are saving data, if the filename you choose has a dot in it, the data will not be saved. Sadly, the data will also be lost and the user will never know that the problem was having a simple . in the filename.

Handling all possible errors in a graceful way is very complicated and sometimes almost impossible. However, you can see that even the software that ships with very expensive instruments (in this case I mean instruments with a price tag similar to a small apartment), also has to deal with all kinds of exceptions, and not always in the most user-friendly way.

Catching Specific Exceptions

The proper way of handling exceptions in Python is to specify what exception are we expecting. In this way, we know that if the problem is that the file doesn't exist, we can create it, while if the problem is of a different nature, it will be raised and displayed to the user. We can alter the above examples like this:

try:
    file = open('my_file.dat')
    data = file.readfile()
    print('Data Loaded')
except FileNotFoundError:
    print('This file doesn\'t exist')

If you run the script, and the file my_file.dat doesn't exist, it will print to screen that the file doesn't exist and the program will keep running. However, if the file does exist, you will see the exception with readfile. Of course, you are not limited to printing a message when an exception happens. In the case of the non-existing file, it is easy to create one:

try:
    file = open('my_file.dat')
    data = file.readfile()
    print('Data Loaded')
except FileNotFoundError:
    file = open('my_file.dat', 'w')
    print('File created')
file.close()

If you run the script once, you will see that the file is being created. If you run the script a second time, you will see the exception with the readfile method. Imagine that you don't specify which exception you are catching, and you have the following code, what will happen when you run it?:

try:
    file = open('my_file.dat')
    data = file.readfile()
    print('Data Loaded')
except:
    file = open('my_file.dat', 'w')
    print('File created')

If you look carefully, you will realize that even if the file my_file.dat exists, an exception is going to be raised because of the readfile method. Then the except block is going to be executed. In this block, the program is going to create a new my_file.dat, even if it already existed, and therefore you are going to lose the information stored in it.

Re-raising Exceptions

A very common scenario is that when an exception appears, you want to do something but then raise the same exception. This is a very common case when writing to a database or to different files. Imagine the case where you are storing information in two files, in the first one you store spectra and in the second one the temperature at which you acquire each one. You first save the spectra and then the temperature, and you know that each line on one file corresponds to one file on the second file.

Normally, you save first a spectrum and then you save the temperature. However, once in a while, when you try to read from the instrument, it crashes and the temperature is not recorded. If you don't save the temperature, you will have an inconsistency in your data, because a line is missing. At the same time, you don't want the experiment to keep going, because the instrument is frozen. Therefore, you can do the following:

[data already saved]

try:
    temp = instrument.readtemp()
except:
    remove_last_line(data_file)
    raise
save_temperature(temp)

What you can see here is that we try to read the temperature and if anything happens, we will catch it. We remove the last line from our data file, and then we just call raise. This command will simply re-raise anything that was caught by the except. With this strategy, we are sure that we have consistent data, that the program will not keep running and that the user will see all the proper information regarding what went wrong.

Exceptions in Exceptions

Imagine that the code is part of a larger function, responsible for opening a file, loading its contents or creating a new file in case the specified filename doesn't exist. The script will look the same as earlier, with the difference that the filename is going to be a variable:

try:
    file = open(filename)
    data = file.readfile()
except FileNotFoundError:
    file = open(filename, 'w')

To run the code above, the only thing you have to do is to specify the filename before, for instance:

filename = 'my_data.dat'
try:
    [...]

If you run this code, you will notice that it behaves exactly as expected. However, if you specify an empty filename:

filename = ''
try:
    [...]

You will see a much longer error printed to screen, with one important line:

During handling of the above exception, another exception occurred:

If you look carefully at the error, you will see that it outputs information regarding that an error occurred while the code was already handling another error. This is, unfortunately, a common situation, especially when dealing with user input. The way around it would be to nest another try/except block or to verify the integrity of the inputs before calling open.

Several Exceptions

So far we have been dealing with only one possible exception, FileNotFoundError. However, we know that the code will raise two different exceptions, the second one being an AttributeError. If you are not sure about which errors can be raised, you can generate them on purpose. For instance, if you run this code:

file = open('my_data.dat', 'a')
file.readfile()

You will get the following message:

AttributeError: '_io.TextIOWrapper' object has no attribute 'readfile'

The first string is the type of exception, AttributeError, while the second part is the message. The same exception can have different messages, which describe better what has happened. What we want is to catch the AttributeError, but also we want to catch the FileNotFound. Therefore, our code would look like this:

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.readfile()
except FileNotFoundError:
    file = open(filename, 'w')
    print('Created file')
except AttributeError:
    print('Attribute Error')

Now you are dealing with several exceptions. Remember that when an exception is raised within the try block, the rest of the code will not be executed, and Python will go through the different except blocks. Therefore, only one exception is raised at a time. In the case where the file doesn't exist, the code will deal only with the FileNotFoundError.

Of course, you can also add a final exception to catch all other possible errors in the program, like this:

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.read()
    important_data = data[0]
except FileNotFoundError:
    file = open(filename, 'w')
    print('Created file')
except AttributeError:
    print('Attribute Error')
except:
    print('Unhandled exception')

In this case, if the file exists but it is empty, we are going to have a problem trying to access data[0]. We are not prepared for that exception and therefore we are going to print a message saying Unhandled exception. It would be, however, more interesting to let the user know what exception was actually raised. We can do the following:

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.read()
    important_data = data[0]
except Exception as e:
    print('Unhandled exception')
    print(e)

Which will output the following message:

Unhandled exception
string index out of range

The exception also has a type, which you can use. For example:

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.read()
    important_data = data[0]
except Exception as e:
    print('Unhandled exception')
    if isinstance(e, IndexError):
        print(e)
        data = 'Information'
        important_data = data[0]

print(important_data)

Which will print the first letter of Information, i.e. I. The pattern above has a very important drawback, and is that important_data may end up not being defined. For example, if the file my_data.dat doesn't exist, we will get another error:

NameError: name 'important_data' is not defined

The Finally Statement

To prevent what we just saw in the previous section, we can add one more block to the sequence: finally. This block is always going to be executed, regardless of whether an exception was raised or not. For example:

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.read()
    important_data = data[0]
except Exception as e:
    if isinstance(e, IndexError):
        print(e)
        data = 'Information'
        important_data = data[0]
    else:
        print('Unhandled exception')
finally:
    important_data = 'A'

print(important_data)

This is, in the end, a very silly example, because we are setting important_data to a special value, but I hope you can see the use of finally. If there is something that you must absolutely be sure that is executed, you can include it in a finally statement.

finally is very useful to be sure that you are closing a connection, the communication with a device, closing a file, etc. Generally speaking, releasing the resources. Finally has a very interesting behavior, because it is not executed always at the same moment. Let's see the following code:

filename = 'my_data.dat'

try:
    print('In the try block')
    file = open(filename)
    data = file.read()
    important_data = data[0]
except FileNotFoundError:
    print('File not found, creating one')
    file = open(filename, 'w')
finally:
    print('Finally, closing the file')
    file.close()
    important_data = 'A'

print(important_data)

First, run the code when the file my_data.dat doesn't exist. You should see the following output:

In the try block
File not found, creating one
Finally, closing the file

So, you see you went from the try to the except to the finally. If you run the code again, the file will exist, and therefore the output will be completely different:

In the try block
Finally, closing the file
Traceback (most recent call last):
  File "JJ_exceptions.py", line 7, in <module>
    important_data = data[0]
IndexError: string index out of range

What you can see here is that when an unhandled exception is raised, the first block to be executed is the finally. You close the file immediately. And then, the error is re-raised. This is very handy because it prevents any kind of conflict with downstream code. You open, you close the file and then the rest of the program has to deal with the problem of the IndexError. If you want to try a program without exceptions, just write something into my_data.dat and you will see the output.

The else Block

There is only one more block to discuss in the exception handling pattern, the else block. The core idea of this block is that it gets executed if there were no exceptions within the try block. Is very easy to understand how it works, you could, for example, do the following:

filename = 'my_data.dat'

try:
    file = open(filename)
except FileNotFoundError:
    print('File not found, creating one')
    file = open(filename, 'w')
else:
    data = file.read()
    important_data = data[0]

The most difficult part of the else block is understanding its usefulness. In principle, the code that we have included in the else block could have also been placed right after opening the file, as we have done earlier. However, we can use the else block to prevent catching exceptions that do not belong to the try. It is a bit far-fetched examples, but imagine that you need to read a filename from a file and open it. The code would look like this:

try:
    file = open(filename)
    new_filename = file.readline()
except FileNotFoundError:
    print('File not found, creating one')
    file = open(filename, 'w')
else:
    new_file = open(new_filename)
    data = new_file.read()

Since we are opening two files, it may very well be that the problem is that the second file doesn't exist. If we would put this code into the try block, we would end up triggering the except for the second file even if we didn't mean to. At first, it is not obvious the true use of the else block, but it can be very useful and therefore it is important that you are aware that it exists.

Of course, it is possible to combine everything that you have learned so far:

try:
    file = open(filename)
    new_filename = file.readline()
except FileNotFoundError:
    print('File not found, creating one')
    file = open(filename, 'w')
else:
    new_file = open(new_filename)
    data = new_file.read()
finally:
    file.close()

You are very encouraged to play around and try to find different usages for each block. If you have worked enough with Python, probably you encounter plenty of exceptions that forced you to re-run your script from the beginning. Now you know that there may be workarounds. A great resource, as almost always, is the Python Documentation on Exceptions.

Things are not over yet, there are many more things that can be done with exceptions.

The Traceback

As you have probably seen already, when there is an exception, a lot of information is printed to the screen. For example, if you try to open a not existing file you get:

Traceback (most recent call last):
  File "P_traceback.py", line 13, in <module>
    file = open(filename)
FileNotFoundError: [Errno 2] No such file or directory: 'my_data.dat'

Interpreting the message may take a bit of practice, but for simple cases it is clear. First, it tells you that you are seeing a traceback, in simple words the history of things that lead to the exception. I will cover more on this on a separate post. However, you can clearly see the file that generated the problem and the line. If you open the file and go to that line, you will see that it is exactly the one that says file = open(filename). Finally, you see the exception.

This last message is the one we were printing to screen, but we were neglecting the traceback that would allow us to find the real source of the exception and act accordingly. Fortunately, Python allows us to access the traceback very easily. Slightly modifying the example of opening a file, we would have:

import traceback

filename = 'my_data.dat'

try:
    file = open(filename)
    data = file.read()
except FileNotFoundError:
    traceback.print_exc()

If you run the code again, you will see printed to screen the same information than before. The main difference is that your program didn't crash, because you were handling the exception. Working with tracebacks is very handy for debugging. The examples that you have seen here are very simple, but when you have a very nested code, i.e., one function calls another that creates an object, that runs a method, etc. it is very important to pay attention to the traceback in order to know what triggered the exception.

Raising Custom Exceptions

When you are developing your own packages, it is often useful to define some common exceptions. This gives a great deal of flexibility because it allows other developers to handle those exceptions as they find appropriate. Let's see an example. Imagine that you want to write a function that calculates the average between two numbers, but you want both numbers to be positive. This is the same example that we have seen when working with decorators. We start by defining the function:

def average(x, y):
    return (x + y)/2

And now we want to raise an Exception if either input is negative. We can do the following:

def average(x, y):
    if x<=0 or y<=0:
        raise Exception('Both x and y should be positive')
    return (x + y)/2

If you try it yourself with a negative input, you will see the following printed:

Exception: Both x and y should be positive

Which is great, it even points to the line number with the issue, etc. However, if you are building a module and you expect others to use it, it would be much better to define a custom Exception, that can be explicitly caught. It is as easy as this:

class NonPositiveError(Exception):
    pass

def average(x, y):
    if x <= 0 or y <= 0:
        raise NonPositiveError('Both x and y should be positive')
    return (x + y) / 2

An exception is a class, and therefore it should inherit from the general Exception class. We don't really need to customize anything at this stage, we just type pass in the body of the class. If we run the code above with a negative value, we will get:

NonPositiveError: Both x and y should be positive

If you want to catch that exception in downstream code, you will do it as always. The only difference is that custom exceptions are not available by default and you should import them. For example, you would do the following:

from exceptions import NonPositiveError
from tools import average

try:
    avg = average(1, -2)
except NonPositiveError:
    avg = 0

If you have worked long enough with packages, probably you have already encountered a lot of different exceptions. They are a great tool to let the user know exactly what was wrong and act accordingly. Sometimes we can be prepared for some exceptions and is very appreciated when custom ones are included into the package and not just a generic one that forces us to catch any exception, even if it is something that we were not actually expecting.

Best Practices for Custom Exceptions

When you are developing a package, it is very handy to define exceptions that are exclusive to it. This makes it much easier to handle different behaviors and gives developers a very efficient way to filter whether the problems are within your package or with something else. Imagine, for instance, that you are working with a complex package, and you want to write to a file every time an exception from that specific package appears.

This is very easy to achieve if all the exceptions inherit from the same base class. The code below is a bit longer, but it is built on top of all the examples above, so it should be easy to follow:

class MyException(BaseException):
    pass

class NonPositiveIntegerError(MyException):
    pass

class TooBigIntegerError(MyException):
    pass

def average(x, y):
    if x<=0 or y<=0:
        raise NonPositiveIntegerError('Either x or y is not positive')

    if x>10 or y>10:
        raise TooBigIntegerError('Either x or y is too large')
    return (x+y)/2

try:
    average(1, -1)
except MyException as e:
    print(e)

try:
    average(11, 1)
except MyException as e:
    print(e)

try:
    average('a', 'b')
except MyException as e:
    print(e)

print('Done')

We first define an exception called MyException, which is going to be our base exception. We then define two errors, NonPositiveIntegerError and TooBigIntegerError which inherit from MyException. We define the function average again but this time we raise two different exceptions. If one of the numbers is negative or larger than 10.

When you see the different use cases below, you will notice that in the try/except block, we are always catching MyException, but not one of the specific errors. In the first two examples, when passing -1 and 11 as arguments, we successfully print to screen the error message, and the program keeps running. However, when we try to calculate the average between two letters, the Exception is going to be of a different nature, and is not going to be caught by the Except. You should see the following on your screen:

TypeError: '<=' not supported between instances of 'str' and 'int'

Adding Arguments to Exceptions

Sometimes it is handy to add arguments to exceptions in order to give a better context to users. With the example of the average, let's first define a more complex exception:

class MyException(BaseException):
    pass

class NonPositiveIntegerError(MyException):
    def __init__(self, x, y):
        super(NonPositiveIntegerError, self).__init__()
        if x<=0 and y<=0:
            self.msg = 'Both x and y are negative: x={}, y={}'.format(x, y)
        elif x<=0:
            self.msg = 'Only x is negative: x={}'.format(x)
        elif y<=0:
            self.msg = 'Only y is negative: y={}'.format(y)

    def __str__(self):
        return self.msg


def average(x, y):
    if x<=0 or y<=0:
        raise NonPositiveIntegerError(x, y)
    return (x+y)/2

try:
    average(1, -1)
except MyException as e:
    print(e)

What you can see is that the exception takes two arguments, x and y and it generates a message based on them. They can be both negative or only one of them is negative. It doesn't only give you that information, but it actually displays the value that gave problems. This is very handy to understand what went wrong exactly. The most important part is at the end of the class: the __str__ method. This method is responsible for what appears on the screen when you do print(e) in the except block. In this case, we are just returning the message generated within the __init__, but many developers choose to generate the message in this method, based on the parameters passed at the beginning.

Conclusions

Exceptions are something nobody wants to see but they are virtually unavoidable. Maybe you try to read a file that doesn't exist, the user of your code has chosen invalid values, the matrix you are analyzing has different dimensions than expected, etc. Handling exceptions is a sensitive topic because it can lead to even more problems downstream. An Exception is a clear message that there is something wrong going on and if you don't fix it properly, it is going to become even worse.

Handling exceptions can help you to avoid having inconsistent data, not closing resources such as devices, connections or files, etc. However, not handling exceptions correctly can lead to even more problems later on. The try/except block is very handy when you know what kind of exceptions can appear and you know how to handle them. Imagine you are performing several steps of a complex operation, like writing to a database. If an error happens, you can revert all the changes and avoid inconsistencies.

As with almost any other Python topic, the best way to learn is to look closely at other's code and judge by yourself. Not all packages define their own exceptions, nor handle them in the same way. If you are looking for inspiration, you can see the errors of Pint, a relatively small package, or the exceptions of Django, a much more complex package.

Photo by Cody Davis on Unsplash

Article written by Aquiles Carattino
Join our newsletter!
If you liked the content, sign up to never miss an update.

Share your thoughts with us!

Support Us

If you like the content of this website, consider buying a copy of the book Python For The Lab