Exceptions Lesson
Site: | Saylor Academy |
Course: | CS105: Introduction to Python |
Book: | Exceptions Lesson |
Printed by: | Guest user |
Date: | Wednesday, May 14, 2025, 2:17 AM |
Description
Now that the motivation and some syntax for exceptions has been presented, we can delve a bit deeper into the structure of writing programs containing exceptions. The following lesson is designed to help put into context the rudiments just presented. In addition, one more important component of exception handling is the 'raise' statement which is useful for raising an exception if, for example, it occurs inside an exception handler.
Exceptions
In this chapter, you'll learn all about Exceptions in Python. We'll start by reviewing the exception hierarchy, raising exceptions, examining try and catch, and finally how to create our own Exception classes.
Source: Nina Zakharenko, https://www.learnpython.dev/03-intermediate-python/40-exceptions/
This work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 License.
1. All About Exceptions
ALL ABOUT EXCEPTIONS
Built-in exceptions and easy exception handling is one of the shining features of Python. Technically, errors that happen during parsing are called SyntaxError
s - these
will probably be the most common errors you see, and usually happen because of a mistake in whitespace, a syntax misunderstanding, or a simple typo.
Even if the syntax is correct, errors can still occur when your program is run. We call these Exceptions, and there are many different types (this is a good thing, because the more specifically we know what went wrong, the better we can handle it).
An un-handled exception is fatal: it will print debugging information (called a traceback), stop the interpreter, and exit your program. However, once you learn to handle Exceptions, you can cover your bases and write programs that are robust in the face
of issues.
Types of Exceptions
Python has many useful built-in exceptions that you'll probably encounter in your travels. Some of the more common ones that you'll run into are:
Exception | Cause of Error |
---|---|
AttributeError | Raised when attribute assignment or reference fails. |
ImportError | Raised when the imported module is not found. |
IndexError | Raised when index of a sequence is out of range. |
KeyError | Raised when a key is not found in a dictionary. |
KeyboardInterrupt | Raised when the user hits interrupt key (Ctrl+c or delete). |
NameError | Raised when a variable is not found in local or global scope. |
SyntaxError | Raised by parser when syntax error is encountered. |
IndentationError | Raised when there is incorrect indentation. |
ValueError | Raised when a function gets argument of correct type but improper value. |
You can find a more detailed list of built-in exceptions in the Python documentation
.
Exception Hierarchy
An important thing to know is that exceptions, like everything else in Python, are just objects. They follow an inheritance hierarchy, just like classes do. For example, the ZeroDivisionError
is
a subclass of
ArithmeticError
, which is a subclass of Exception
, itself a subclass of
BaseException
.
So, if you wanted to catch a divide-by-zero error, you could use except ZeroDivisionError
. But you could also use
except ArithmeticError
, which would catch not only
ZeroDivisionEror
, but also OverflowError
and FloatingPointError
. You could use except Exception
, but this is not
a good idea, as it will catch almost every type of error, even ones you weren't expecting. We'll discuss this a bit later. Again, a full chart of the hierarchy for built-in exceptions can be found at the bottom of the (Python
documentation)[
https://docs.python.org/3/library/exceptions.html#exception-hierarchy].
Exiting your Program
As we mentioned, exceptions that are allowed to bubble up to the top level (called unhandled exceptions) will cause your program to exit. This is generally unwanted - even if an error is unrecoverable, we still want to provide more detailed information about the error for later inspection, or a pretty error for the user if our program is user-facing, and in most cases, we want the program to go back to doing what it was doing.
What if we want our program to stop, though? You may already be familiar with ctrl-c
, the age-old posix method of sending SIGINT (an interrupt signal) to a program. You may be surprised to know asking your operating system to send SIGINT to Python causes, yes, an exception - KeyboardInterrupt
. And yes, you can catch KeyboardInterrupt
, but this will make your program a little harder to kill.
You can also use sys.exit()
from the built-in sys
library. It's generally not a good idea to pepper sys.exit()
around
your code, as it makes it harder to control when your program exits, but this can be a handy function for controlling how and when your program exits. By default, sys.exit()
with
no parameters will exit with a
0
return code, which, by posix convention, signals success. You can pass an integer to sys.exit()
if
you'd like to exit with a non-zero return code (usually signaling some sort of failure condition). You can also pass a string to sys.exit()
,
which will get printed to the command line, along with a return code of 1
.
sys.exit()
generates a SystemExit
exception,
which inherits from the master BaseException
class, which makes it possible for clean-up handlers (such as finally
statements)
to run.
2. Try Except
TRY EXCEPT
Many languages have the concept of the "Try-Catch" block. Python uses four keywords: try
, except
,
else
, and finally
. Code that can possibly throw an exception goes in the
try
block.
except
gets the code that runs if an exception is raised. else
is an optional
block that runs if no exception was raised in the try
block, and finally
is
an optional block of code that will run last, regardless of if an exception was raised. We'll focus on try
and except
for this chapter.
A basic example looks like this:
>>> try:
... x = int(input("Enter a number: "))
... except ValueError:
... print("That number was invalid")
First, the try
clause is executed. If no exception occurs, the except
clause is skipped and execution of the try
statement is finished. If an exception occurs in the try
clause, the rest of the clause is skipped. If the exception's type matches the exception named after the except
keyword, then the
except
clause is executed. If the exception doesn't match, then the exception is unhandled and execution stops.
The except
Clause
An except
clause may have multiple exceptions, given as a parenthesized tuple:
try:
# Code to try
except (RuntimeError, TypeError, NameError):
# Code to run if one of these exceptions is hit
A try
statement can also have more than one except
clause:
try:
# Code to try
except RuntimeError:
# Code to run if there's a RuntimeError
except TypeError:
# Code to run if there's a TypeError
except NameError:
# Code to run if there's a NameError
Finally
Finally, we have finally
. finally
is an optional block that runs after
try
, except
, and else
,
regardless of if an exception is thrown or not. This is good for doing any cleanup that you want to happen, whether or not an exception is thrown.
>>> try:
... raise KeyboardInterrupt
... finally:
... print("Goodbye!")
...
Goodbye!
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
KeyboardInterrupt
As you can see, our Goodbye! gets printed just before the unhandled KeyboardInterrupt
gets propagated up and triggers the traceback.
3. Best Practices
BEST PRACTICES
Catch More Specific Exceptions First
Remember, your except
handlers are evaluated in order, so be sure to put more specific exceptions first. For example:
>>> try:
... my_value = 3.14 / 0
... except ArithmeticError:
... print("We had a general math error")
... except ZeroDivisionEror:
... print("We had a divide-by-zero error")
...
We had a general math error
When we tried to divide by zero, we inadvertently raised a ZeroDivisionError. However, because ZeroDivisionError is a subclass of ArithmeticError, and except ArithemticError
came first, the information about our specific error was swallowed by the except ArithemticError
handler, and we lost more detailed information about our error.
Don't Catch Exception
It's bad form to catch the general Exception
class. This will catch every type of exception that subclasses the Exception
class,
which is almost all of them. You may have errors that you don't care about, and don't affect the operation of your program, or maybe you're dealing with a flaky API and want to swallow errors and retry. By catching Exception
, you run the risk of hitting an unexpected exception that your program actually can't recover from, or worse, swallowing an important exception without properly logging it - a huge headache when trying
to debug programs that are failing in weird ways.
Definitely don't catch BaseException
Catching BaseException
is a really bad idea, because you'll swallow every type of Exception, including KeyboardInterrupt
,
the exception that causes your program to exit when you send a SIGINT (Ctrl-C). Don't do it.
4. Custom Exceptions
CUSTOM EXCEPTIONS
As we mentioned, exceptions are just regular classes that inherit from the Exception
class. This makes it super easy to create our own custom exceptions, which can make our programs easier to follow and more
readable. An exception need not be complicated, just inherit from Exception
:
>>> class MyCustomException(Exception):
... pass
...
>>> raise MyCustomException()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
__main__.MyCustomException
It's OK to have a custom Exception
subclass that only pass
-es - your exception doesn't need to do anything fancy to be useful. Having custom exceptions - tailored to your
specific use cases and that you can raise and catch in specific circumstances - can make your code much more readable and robust, and reduce the amount of code you write later to try and figure out what exactly went wrong.
Of course, you can get as fancy as you want. You can send additional information, like messages, to your exceptions. Just add an __init__()
method to your exception class, with whatever arguments you want.
class IncorrectValueError(Exception):
... def __init__(self, value):
... message = f"Got an incorrect value of {value}"
... super().__init__(message)
...
>>> my_value = 9999
>>> if my_value > 100:
... raise IncorrectValueError(my_value)
...
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
__main__.IncorrectValueError: Got an incorrect value of 9999
Exception
takes an optional string argument message that gets printed with your exception. We pass our erroneous value to our IncorrectValueError
object, which constructs
a special message and passes it its parent class, Exception
, via super().__init__()
. The custom message string, along with the value for context, gets printed along with
our error traceback.
A Custom Exception for our GitHub API app
If we wanted to write a custom Exception for our GitHub API app, it might look something like this.
class GitHubApiException(Exception):
def __init__(self, status_code):
if status_code == 403:
message = "Rate limit reached. Please wait a minute and try again."
else:
message = f"HTTP Status Code was: {status_code}."
super().__init__(message)
Notice how it takes the HTTP status code into account, and displays a custom error message for the 403, rate limited reached status code.
5. Practice
PRACTICE
Syntax Errors
Let's get more comfortable with exceptions. First, you've probably seen this one already:
The IndentationError
.
>>> def my_function():
... print("Hello!")
File "<stdin>", line 2
print("Hello!")
^
IndentationError: expected an indented block
Notice that we started a new function scope with the def
keyword, but didn't indent the next line of the function, the
print()
argument.
You've probably also seen the more general SyntaxError
. This one's probably obvious - something is misspelled, or the syntax is otherwise wrong. Python gives us a helpful little caret
^
under the earliest point
where the error was detected, however you'll have to learn to read this with a critical eye as sometimes the actual mistake precedes the invalid syntax. For example:
>>> a = [4,
... x = 5
File "<stdin>", line 2
x = 5
^
SyntaxError: invalid syntax
Here, the invalid syntax is x = 5
, because assignment statements aren't valid list elements, however the actual error is the missing right bracket
]
on the line above.
Common Exceptions
You'll get plenty of practice triggering syntax errors on your own. Let's practice triggering some exceptions. Type this perfectly valid code into your REPL and see what happens:
>>> a = 1 / 0
>>> a = 1 / 0
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero
Of course, you'll get a divide-by-zero error, or as Python calls it, ZeroDivisionError
. Some other common errors are
TypeError
when trying to perform an action on two unrelated types, KeyError
when
trying to access a dictionary key that doesn't exist, and AttributeError
when trying to access a variable or call a function that doesn't exist on an object.
>>> 2 + "3"
>>> my_dict = {"hello": "world"}
>>> my_dict["foo"]
>>> my_dict.append("foo")
>>> 2 + "3"
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'
>>> my_dict = {"hello": "world"}
>>> my_dict["foo"]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'foo'
>>> my_dict.append("foo")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'dict' object has no attribute 'append'
Raising our own Exceptions
Making our own Exceptions is cheap and easy, and useful for keeping track of various error states that are specific to your application. Simply inherit from the general Exception
class:
>>> class MyException(Exception):
... pass
>>> raise MyException()
>>> class MyException(Exception):
... pass
>>> raise MyException()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
__main__.MyException
It's also sometimes helpful to change the default behavior for your custom Exceptions. In this case, you can simply provide your own __init__()
method inside your Exception subclass:
class MyException(Exception):
... def __init__(self, message):
... new_message = f"!!!ERROR!!! {message}"
... super().__init__(new_message)
...
>>> raise MyException("Something went wrong!")
class MyException(Exception):
... def __init__(self, message):
... new_message = f"!!!ERROR!!! {message}"
... super().__init__(new_message)
...
>>> raise MyException("Something went wrong!")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
__main__.MyException: !!!ERROR!!! Something went wrong!
try
, except
In Python, the "try-catch" statements use try
and except
. As we discussed,
try
is the code that could possibly throw an Exception, and except
is the code that runs if the error is raised. Practice
catching a KeyError
by try
ing to access a fake dictionary key:
>>> try:
... my_dict = {"hello": "world"}
... print(my_dict["foo"])
... except KeyError:
... print("Oh no! That key doesn't exist")
...
>>> try:
... my_dict = {"hello": "world"}
... print(my_dict["foo"])
... except KeyError:
... print("Oh no! That key doesn't exist")
...
Oh no! That key doesn't exist
Let's add in catching the specific KeyError
object so that we can access it during the
except
block:
>>> try:
... my_dict = {"hello": "world"}
... print(my_dict["foo"])
... except KeyError as key_error:
... print(f"Oh no! The key {key_error} doesn't exist!")
...
>>> try:
... my_dict = {"hello": "world"}
... print(my_dict["foo"])
... except KeyError as key_error:
... print(f"Oh no! The key {key_error} doesn't exist!")
...
Oh no! The key 'foo' doesn't exist!
Re-Raising
Sometimes it's helpful to catch an error, perform an action, and then pass the error on rather than swallowing it. This is useful when, for example, something goes wrong deep inside your code and you need to perform a special action, but also let
code further up the chain know that something is wrong and the program can't continue. Let's divide one number by other, decrementing until we hit zero. Catch that error and immediately raise a RuntimeError
:
>>> while True:
... for divisor in range(5, -1, -1):
... try:
... quotient = 10 / divisor
... print(f"10 / {divisor} = {quotient}")
... except ZeroDivisionError:
... print("Oops! We tried to divide by zero!")
... raise RuntimeError
...
>>> while True:
... for divisor in range(5, -1, -1):
... try:
... quotient = 10 / divisor
... print(f"10 / {divisor} = {quotient}")
... except ZeroDivisionError:
... print("Oops! We tried to divide by zero!")
... raise RuntimeError
...
10 / 5 = 2.0
10 / 4 = 2.5
10 / 3 = 3.3333333333333335
10 / 2 = 5.0
10 / 1 = 10.0
Oops! We tried to divide by zero!
Traceback (most recent call last):
File "<stdin>", line 4, in <module>
ZeroDivisionError: division by zero
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "<stdin>", line 8, in <module>
RuntimeError
What happened here? We got two exceptions! First, our code hit the ZeroDivisionError
, which we caught, and printed our "Oops!" message. Then, the interpreter saw that we raised a
RuntimeError
, which we didn't
catch, so it broke us out of our while True
loop and ended the program with a Traceback.