Python Exception Handling and Customization

Erik - Sep 17 '23 - - Dev Community

Like bugs, exceptions are inevitable when developing software, especially as the complexity of that software increases. Sometimes exceptions are surprising, other times we can anticipate them coming. How a program responds to the occurrence of exceptions is called exception handling, and as programmers, we can define and customize exception handling. In this chapter, we’ll learn what exceptions are, how to handle them, and how to make our own.

What are Exceptions?

Exceptions can be thought of as unplanned events in the execution of a program that disrupt that execution. When a runtime error occurs, a specific exception is raised. If an exception is raised and the program does not have any code defining how to handle that exception, the exception is said to be uncaught, and it will terminate execution of the program. Exceptions are not unique to Python; every programming language has exceptions and a means of handling them. You’ve actually most likely seen lots of exceptions already, but just for clarity, let’s raise a few exceptions on purpose.

First, in a Python console, try to divide any number by zero and press enter. You should see something similar to the following:

>>> print(3 / 0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero
Enter fullscreen mode Exit fullscreen mode

When we tried to divide by zero, Python raised an exception. In this case, the exception raised was ZeroDivisionError. ZeroDivisionError is the name of the exception raised when a program tries to divide by zero. There are other kinds of exceptions as well, let’s raise another. In the Python console, try to add a number to a string:

>>> print('hi' + 2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can only concatenate str (not "int") to str
Enter fullscreen mode Exit fullscreen mode

This time, Python raised a TypeError. TypeError is the kind of exception raised when a program tries to do something to an object that the object’s class doesn’t support. Notice also that there’s a message along with the exception: can only concatenate str (not “in”) to str. Exceptions can, and often do, have different messages for different ways in which they can be raised. For example, let’s raise another TypeError, this time by trying to divide a number by a string:

>>> print(20 / 'hi')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for /: 'int' and 'str'
Enter fullscreen mode Exit fullscreen mode

Again, Python raises a TypeError in this situation, but notice that the message is different. The message says unsupported operand type(s) for /: ‘int’ and ‘str’. This will be relevant later when we write our own exceptions.

Now that we have an intuition about what exceptions are, lets learn how to handle them.

Handling Exceptions

An exception will terminate the execution of a program if left unhandled. For example, consider the following script:

def divide(x, y):
  return x / y

print(divide(4, 2))
print(divide(2, 0))
print(divide(9, 3))
Enter fullscreen mode Exit fullscreen mode

In this script, we have a method called divide that takes two parameters, divides them, and returns the result. Next, we have three lines calling the divide function and printing the result. Notice that the second line passes zero as a second parameter; let’s see what happens when we try to run this script:

2.0
Traceback (most recent call last):
  File "path/to/script.py", line 5, in <module>
    print(divide(2, 0))
  File "path/to/script.py", line 2, in divide
    return x / y
ZeroDivisionError: division by zero
Enter fullscreen mode Exit fullscreen mode

Notice that the program printed 2.0 to the console, meaning the first call to the divide function worked. Then, the program raised an exception, ZeroDivisionError, on the second line because we tried to divide by zero. Notice also that the program did not run the final line, print(divide(9, 3)). This is because the ZeroDivisionError was an uncaught exception. In order to make the program continue running, we have to write code specifically for handling that exception. We do this in Python with try except blocks.

Using try and except

In Python, we use the keywords try and except to handle exceptions. The try block contains the operation that may raise an exception, and the except block contains the code that defines what we want the program to do when an exception is raised. As an example, let’s rewrite the divide function from earlier to handle ZeroDivisionError exceptions:

def divide(x, y):
  try:
    return x / y
  except ZeroDivisionError:
     return "Cannot divide by zero"
Enter fullscreen mode Exit fullscreen mode

Notice that the line return x / y is now in a try block. This tells Python to run the code in this block until the last line, or until an exception is raised. If an exception is raised, Python checks for an except block that matches the type of exception. In our case, we wrote except ZeroDivisionError, so if a ZeroDivisionError is raised, Python will run the code inside of the except block. To see this is action, run the script with the updated divide function. You should see the following output in the console:

2.0
Cannot divide by zero
3.0
Enter fullscreen mode Exit fullscreen mode

This time, when the script got to the line that said print(divide(2, 0)), the program did not print the exception message to the console, it instead ran the code in the except block, which we can see in the console when it printed Cannot divide by zero. Notice also that even though an exception was raised, the program continued execution, as we can see by the 3.0 printed to the console when Python ran the last line of our script. This is an example of how exception handling is useful, we can anticipate problems and handle them without terminating the program.

Note: When a program encounters an error or otherwise fails in some way but does not terminate its execution, it is said to fail gracefully.

Handling Other Types of Exceptions

The divide function can now fail gracefully when a ZeroDivisionError exception is raised, but what about other exceptions? What if someone tries to pass a string to the function? As an example, update the last lines in the previous script to look like the following:

print(divide(4, 2))
print(divide(2, 0))
print(divide('hi', 2))
print(divide(9, 3))
Enter fullscreen mode Exit fullscreen mode

If you try to run the script now, you’ll see the following output:

2.0
Cannot divide by zero
Traceback (most recent call last):
  File "path/to/script.py", line 14, in <module>
    print(divide('hi', 2))
  File "path/to/script.py", line 7, in divide
    return x / y
TypeError: unsupported operand type(s) for /: 'str' and 'int'
Enter fullscreen mode Exit fullscreen mode

Notice this time that Python raised a TypeError when we tried to divide a string with an integer. We never wrote any code to handle this exception, so Python terminated the program once this exception was raised. If we want to add code to handle this exception, we have two options.

First, we could add another except block to the divide function for that specific exception. The function would then look like the following:

def divide(x, y):
  try:
    return x / y
  except ZeroDivisionError:
     return "Cannot divide by zero"
  except TypeError:
    return "Can only divide number types"
Enter fullscreen mode Exit fullscreen mode

This would handle both ZeroDivisionError and TypeError exceptions. But there are yet more exceptions, we cannot anticipate them all. Instead of writing an except block for each exception type that could possibly beraised, we can write an except block that catches all exceptions besides the ZeroDivisionError one. This approach would have the function looking like this:

def divide(x, y):
  try:
    return x / y
  except ZeroDivisionError:
     return "Cannot divide by zero"
  except:
    return "An error occurred"
Enter fullscreen mode Exit fullscreen mode

With the divide function written this way, there is no exception that can be raised which would terminate execution of our program. We have an except block dedicated specifically to catching ZeroDivisionError exceptions and informing the user they cannot divide by zero, and we have an except block that catches any other kind of exception that could possibly be raised and simply alerts the user that an error occurred. These are the basics of handling exceptions in Python but there’s one more thing to learn about, finally blocks.

Using finally

When a line of code raises an exception, Python immediately leaves that line and goes to the except block (or terminates the program) meaning that nothing under the line raising the exception gets executed. To get an idea why this is a problem, consider the following script:

def add(x, y):
  try:
    print(x + y)
    print("Add function completed")
  except:
    print("An error occurred")

add(2, 4)
add('hi', 9)
Enter fullscreen mode Exit fullscreen mode

Here, we’ve defined an add function that tries to add its parameters together and print the result to the console, then prints that it has completed. An except block catches any exceptions and prints that an error has occurred. Under the function we call the function twice, once with numbers and once with a string and a number, forcing an exception. Let’s see what happens:

6
Add function completed
An error occurred
Enter fullscreen mode Exit fullscreen mode

Notice that when the exception was raised, we didn’t see Add function completed printed to the console. This is because once the error was raised, the Python interpreter left the try block and went to the except block, leaving the print statement unexecuted. If we want to execute code regardless of if an error is raised or not, we have to use a finally block. The finally keyword is used like so:

def add(x, y):
  try:
    print(x + y)
  except:
    print("An error occurred")
  finally:
    print("Add function completed")
Enter fullscreen mode Exit fullscreen mode

We’ve added a finally block and put the line printing Add function completed in it. This will ensure that this line runs every time the function is called, regardless of whether there is a caught exception. Run the script again and you should see the following output:

6
Add function completed
An error occurred
Add function completed
Enter fullscreen mode Exit fullscreen mode

This time, the message Add function completed prints to the console every time. This is how finally blocks work.

Now we know how to handle exceptions with try, except, and finally. There is one other thing you should know about exception handling before we move on to customized exceptions. Handling exceptions is computationally slow. This is for a variety of reasons that are much too technical to be relevant to this chapter. The main point is that using exception handling to handle errors may be slower than other techniques (like if statements). Exception handling is best used when we don’t want errors to terminate program execution. Otherwise, it’s often better to use if statements or to just let the program execution terminate. With that caution out of the way, lets talk about customized exceptions.

Customized Exceptions

In the previous sections, we raised two different types of exceptions, ZeroDivisionError and TypeError. These exception classes are just two of many other built-in exception classes in Python. Other exception classes include ImportError, ModuleNotFoundError, KeyError, and many more. In addition to these built-in exception classes, we can also make our own custom exception classes. Customizing exception classes is useful in large projects because it aids in debugging, and it helps you define how errors are handled in your system.

Creating a Custom Exception Class

To create a customized exception, we simply write a class that inherits Python’s Exception class. Write the following:

class MyError(Exception):
  pass
Enter fullscreen mode Exit fullscreen mode

As you can see, writing a custom exception is just a matter of making a subclass of Exception. Now, to raise this exception, we use the raise keyword. Consider the following script:

for i in range(1, 5):
  print(i)
  if i == 3:
    raise MyError
Enter fullscreen mode Exit fullscreen mode

In this script, we make a simple loop that prints its iteration number but raises our customized MyError exception if the number is 3. If you run this script, you should see the following output:

1
2
3
Traceback (most recent call last):
  File "path/to/script.py", line 8, in <module>
    raise MyError
__main__.MyError
Enter fullscreen mode Exit fullscreen mode

Notice that in the output, we see that our custom exception was raised. Suppose we did not know about this script but found a bug report saying “some method is throwing a MyError exception.” Since this is a custom (that is, non-built-in) exception class, we can search our codebase for any line that says raise MyError and figure out what’s going on. When developing large and complex software systems, saving time by searching for customized exceptions is very helpful.

Editing the Exception Message

We can further customize our exception classes by overriding their constructor methods and defining what message they print to the console. For example, suppose we are writing a human resources application for tracking employee salaries. We might have an Employee class like the following:

class Employee:
  def __init__(self, name, salary):
    self.name = name
    self.salary = salary
Enter fullscreen mode Exit fullscreen mode

Here we have a basic Employee class that takes a name and salary attribute in its constructor. Now suppose we wanted to ensure that an employee’s salary is between 20 thousand and 500 thousand and we want to not only raise an exception when someone attempts to make an Employee object with a salary attribute outside of that range, we also want to log such an event to a database. First, we have to create a custom exception, we’ll call it SalaryError. Then, we’ll override its constructor to print why an exception was raised and to log the exception to a database. Check it out:

class SalaryError(Exception):
  def __init__(self, salary):
    self.message = f"Salary must be between 20k and 500k, you put {salary}"
    print("Logging the following to the database:")
    print(f"Attempted to create employee with salary {salary}")
    super().__init__(self.message)
Enter fullscreen mode Exit fullscreen mode

In this code, we create a new class called SalaryError and inherit the Exception class, creating a custom exception. Then, we override the constructor by telling it to expect an input called salary. Then, we set the exception message to inform that a salary attribute must be between 20,000 and 500,000 and put what the attempted salary was.

Next, we print that we are logging the error to the database (in this example, we don’t actually log anything to a database since setting up the necessary connections for that would take away from the focus of this article). Finally, we use super() to fill out the base Exception class’s information so that the class will behave as a standard Python exception.

In order to implement this exception, we’ll tweak the Employee constructor to raise the exception if the salary attribute falls out of the desired range:

class Employee:
  def __init__(self, name, salary):
    if salary < 20000 or salary > 500000:
      raise SalaryError(salary)
    self.name = name
    self.salary = salary
Enter fullscreen mode Exit fullscreen mode

Here, we’ve rewritten the Employee constructor to check if the salary range is within the 20 to 500 thousand range. If it is, we continue with constructing the object; if not, we raise the SalaryError exception. To see how this works, write the following code:

bob = Employee('Bob', 19000)
Enter fullscreen mode Exit fullscreen mode

This code tries to instantiate an Employee object with a salary of 19,000, just below the required range. If you try to run this code, you should see the following output in the console:

Logging the following to the database:
Attempted to create employee with salary 19000
Traceback (most recent call last):
  File "path/to/script.py", line 16, in <module>
    bob = Employee('Bob', 19000)
  File "path/to/script.py", line 11, in __init__
    raise SalaryError(salary)
__main__.SalaryError: Salary must be between 20k and 500k, you put 19000
Enter fullscreen mode Exit fullscreen mode

We can see here that the Employee constructor raised the SalaryError exception and printed out the appropriate error message. It also alerts the user that the error is being logged to the database.

This is the benefit of writing custom exceptions. Not only do we help our future debugging efforts by making exceptions searchable, we also can craft descriptive and helpful error messages. Additionally, custom exceptions let us specify the behavior of our program when encountering errors, allowing us to do things like report errors to a database.

Conclusion

In this article, we improved our debugging and quality assurance skills by learning about exceptions and exception handling in Python. We started by learning about handling exceptions, allowing us to define how the occurrence of certain errors affect the behavior of our programs. Then, we learned how to write our own custom exceptions and how doing so can improve our future troubleshooting efforts. These skills come in handy especially as your software system grows in complexity and usage, since this typically leads to interesting program states that need to be specifically handled.

For more Python-specific information on exceptions, check out chapter 8 in the Python docs.

If you’re trying to get better at Python, try one of my other language-specific tutorials like the delegation/decorator pattern series starting here: Delegate and Decorate in Python: Part 1 – The Delegation Pattern.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .