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
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
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'
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))
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
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"
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
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))
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'
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"
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"
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)
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
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")
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
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
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
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
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
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)
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
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)
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
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.
Top comments (0)