Errors and Exceptions in Python

Errors and Exceptions in Python

Header Image

“If debugging is the process of removing software bugs, then programming must be the process of putting them in.”

― Edsger W. Dijkstra

In your adventures writing code, you will inadvertently introduce bugs to the code or deal with code that has bug(s). Thus debugging, will be a regular process of development. To effectively debug Python code, we need to understand the different kinds of errors we might encounter.

Syntax errors and Exception errors

In Python, errors interrupt the running of a program. They can be broadly grouped into:

  1. Syntax/ Parsing Errors
  2. Exceptions

Syntax/ Parsing Errors

These occur when the parser detects incorrect syntax. See the example below:

for card in cards
  File "<stdin>", line 1
    for card in cards
                     ^
SyntaxError: expected ':'

Here, we forgot to add a colon at the end of the for statement. The caret (^) points to the earliest point where the parser encountered the issue.

Exceptions

Correct syntax doesn't exempt your code from errors during runtime. Python has several built-in exceptions for various runtime errors, and it also allows users to create custom exceptions by subclassing the Exception class.

When an error occurs within a try block, Python looks for a corresponding except block to handle it. If no such block is found, then it is an unhandled exception, and the program will terminate with an error message, which includes a stack traceback and exception details

Here's a brief overview of Python's exception hierarchy and the purpose of some base exception classes:

  • BaseException: This is the top-most base class for all built-in exceptions in Python. It is not intended to be directly inherited by user-defined exceptions. Instead, it serves as the base for system-exit exceptions and other built-in exceptions, such as SystemExit, KeyboardInterrupt, and GeneratorExit. Let us check that they indeed inherit from BaseException. If our condition fails (the check is whether an exception class is a child of BaseException), fails, an AssertError will be raised (bad), else the code will run silently (good).

    for exception_class in {SystemExit, KeyboardInterrupt, GeneratorExit}:
        assert issubclass(exception_class, BaseException)
  • Exception: This is the standard base class for most built-in exceptions and is the superclass from which user-defined exceptions are typically derived. It is subclassed by all exceptions that are meant to be caught and handled within user programs.

  • ArithmeticError: This class serves as the base for built-in exceptions triggered by arithmetic errors like overflow or division by zero. It encompasses specific subclasses such as OverflowError, ZeroDivisionError, and FloatingPointError.

    for exception_class in {OverflowError, ZeroDivisionError, FloatingPointError}:
        assert issubclass(exception_class, BaseException)
  • BufferError: This error is raised when a buffer related operation cannot be performed. Buffers are sequences of objects laid out in memory, and BufferError is related to issues that arise when managing or accessing these.

  • LookupError: This class is the base class for exceptions triggered when a key or index used on a mapping or sequence turns out to be invalid. It has subclasses such as KeyError when a key is not found in a dictionary, and IndexError for when an index is not within the bounds of a list.

    for exception_class in {KeyError, IndexError}:
        assert issubclass(exception_class, BaseException)

Improved Error Messages in Python 3.12

Python 3.12 introduced improved error messages that can make debugging easier. Let's review the changes by comparing messages between Python 3.10 and 3.12:

Let's compare error messages between Python versions 3.10 and 3.12 to see what I mean.

Exhibit A:

Calling a function from the math module without importing it. This will surely raise a NameError exception. In Python 3.12, modules from the standard library are suggested as part of the error message.

Python 3.12

math.sqrt()

Output

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'math' is not defined. Did you forget to import 'math'?

Python 3.10

math.sqrt()

Output

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'math' is not defined

Exhibit B:

You normally write TypeScript but need to whip up some Python. So you (wrongly) write an import statement.

Python 3.12

import sqrt from math

Output

  File "<stdin>", line 1
    import sqrt from math
    ^^^^^^^^^^^^^^^^^^^^^
SyntaxError: Did you mean to use 'from ... import ...' instead?

Python 3.10

import sqrt from math

Output

  File "<stdin>", line 1
    import sqrt from math
                ^^^^
SyntaxError: invalid syntax

Some common exceptions you may encounter

Python's built-in exceptions cover a wide range of error situations. Here's an overview of some common ones you may encounter (examples have been tested with Python 3.12.0):

 python
Python 3.12.0 (main, Dec  6 2023, 14:47:36) [Clang 14.0.3 (clang-1403.0.22.14.1)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
 

AssertionError:

Raised when an assert statement fails. See the example below:

color = "RED"
assert color == "blue"

Output

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError

In the above example, an AssertionError is raised because 2 is not equal to 3.

AttributeError

Raised when an attribute reference or assignment fails. See the example below:

person = {"name": "Doe Doe", "age": 50}
print(person.email)

Output

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'dict' object has no attribute 'email'

In the above example, an AttributeError is raised when we try to reference a non-existent attribute (email) on the dict object.

EOFError

Raised when input() reaches an end-of-file condition without reading any data. Type the line data = input("Enter your name: ") on the python prompt. Press Ctrl+d and see what happens. An EOFError should be raised as shown below:

data = input("Enter your name: ")

Output

Enter your name: Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
EOFError

This is because pressing Ctrl+d forced python to find an end-of -file.

ModuleNotFoundError

Subclass of ImportError. Raised when an imported module could not be located.

See the example below:

import bbfbfm

Output

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ModuleNotFoundError: No module named 'bbfbfm'
 

This is because Python could not find the module bbfbfm as it does not exist.

IndexError

Raised when the index of a sequence is out of range. That is, the index is equal to or greater than the length of the sequence.

See the example below:

simple_list = [0,1,2,3]
len(simple_list)
simple_list[4]

Output

4
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: list index out of range

The above list has 4 elements, we tried to acccess the fifth element thus raising an IndexError.

IndentationError

Subclass of syntax error and is a base class for syntax errors caused by incorrect indentation.

See the example below:

def say_something():
    print()
  File "<stdin>", line 2
    print()
    ^
IndentationError: expected an indented block after function definition on line 1

In the above example, the second line ought to have been indented.

KeyError

Raised when a key is not found in a dictionary's set of keys.

See the example below:

person = {"name": "Doe Doe", "age": 50}
person["email"]

Output

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'email'

In the above example, KeyError was raised because we accessed a non-existent key (email) on the person dictionary. If we want to access a key that may not be present in a dict but do not want to raise a KeyError exception, we can use the get method, available in Python dictionaries. dict.get accepts two arguments, the key to lookup in the dict, and an optional value which is to be returned if the key does not exist. If we do not provide a default value and the key does not exist, the method returns None without raising an exception. The example above could be redone as follows.

person = {"name": "Doe Doe", "age": 50}
person.get("email", "default@email.com")

Output

'default@email.com'

KeyboardInterrupt

Raised when the user hits either ctrl-c or delete.

Let's simulate a long running operation by using time.sleep. time.sleep delays execution for the given number of seconds.

As the execution is delayed, hit ctrl-c and see what happens.

import time
time.sleep(10000)

Output

^CTraceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyboardInterrupt

The execution should stop and a KeyboardInterrupt thrown.

NameError

Raised when a local or global name is not found.

See the example below:

print(email)

Output

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'email' is not defined

In the above example, email was referenced before being assigned.

FileNotFoundError

Raised when an attempt is made to read from a non -existent file or directory.

See the example below:

open("no file")

Output

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'no file'

In the example above, we tried opening a file that does not exist.

OverflowError

In Python, an OverflowError is a specific kind of ArithmeticError that occurs when a calculation exceeds the maximum limit for a numeric type (e.g., integers, floating numbers). However, it is a rare exception in Python 3 because the int type is unbounded. The most common occurrence is with floating-point numbers or functions that operate on them, such as exponentiation.

Raised when the result of an arithmetic operation is too large to be represented by the data type being used, particularly for integers (int) and floating-point numbers (float).

See the example below:

import math
too_large = math.pi ** 10000000

Output

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
OverflowError: (34, 'Result too large')

The result of raising math.pi to the power of 10000000 was too large hence the OverflowError.

RecursionError

Raised when interpreter detects that the maximum recursion depth has been exceeded.

See the example below:

def recurse():
    return recurse()
 
recurse()

Output

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in recurse
  File "<stdin>", line 2, in recurse
  File "<stdin>", line 2, in recurse
  [Previous line repeated 996 more times]
RecursionError: maximum recursion depth exceeded

StopIteration:

This is raised by next() and an iterator's __next__() method to tell that there are no more items to be produced by the iterator.

See the example below:

generator = (element for element in range(4))
next(generator)
next(generator)
next(generator)
next(generator)
next(generator)

Output

0
1
2
3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

SyntaxError

Raised when the parser encounters incorrect syntax.

name = "Jane

Output

 
  File "<stdin>", line 1
    name = "Jane
               ^
SyntaxError: EOL while scanning string literal

TypeError

Raised when an operation is performed to an object of an inappropriate type. For example passing in arguments of the wrong type.

See the example in the snippet below:

str.capitalize(1)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
TypeError: descriptor 'capitalize' requires a 'str' object but received a 'int'

str.capitalize() expects a string but we passed in a different type, an int.

UnboundLocalError

Subclass of NameError. Raised when a local variable is referenced in a function or method, but no value has been bound to that variable.

See the example snippet below:

age = 50
def increment_age():
    print(age)  # print current age
    age += 1  # increase age by one
 
increment_age()

Output

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in increment_age
UnboundLocalError: local variable 'age' referenced before assignment

The above happened because the variable age changed scope. The line age += 1 causes a new assignment and this makes age local to the function scope , consequently shadowing age in the outer scope. So when the call to print (age) happens, we have a problem since we are referencing a variable that has not been assigned. This is because Python now considers age local to increment_age(). Read more on UnboundLocalError here

ValueError

Raised when an operation/function gets an argument of the right type but the wrong value.

See the example below:

import math
math.sqrt(-20)

Output

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: math domain error

In the example above, math.sqrt got an argument of the correct type (int ) but had the wrong value (negative integer)

ZeroDivisionError

Raised when division by zero occurs.

See the example below:

result = 200 / 0

Output

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero

In the above example, ZeroDivisionError was raised by dividing 200 with zero.

Handling errors

Try

A try block is used to wrap potentially problematic code. An except block then catches specific exceptions and handles them accordingly. You can have multiple except statements or handle a group of exceptions.

try:
    raise TypeError("Makosa imefanyika")
except (TypeError, ValueError) as e:
    print(f"An exception occurred: {e}")

Else Clause

The else clause in a try-except block executes if no exceptions are raised in the try block, making it a good place for code that should run only when the try block is successful. That is, no exceptions were raised.

from enum import Enum, auto
 
class Status(Enum):
    PENDING = auto()
    COMPLETE = auto()
    FAILED = auto()
 
task_status = Status.PENDING
 
try:
    with open('file_that_may_or_may_not_exist.txt', 'r') as f:
        data = f.read()
 
except FileNotFoundError:
    task_status = Status.FAILED
 
else:
    task_status = Status.COMPLETE
 
print(f"Task Status: {task_status}")

The Finally Clause

The finally clause executes regardless of whether an exception occurred, which is ideal for cleanup actions such as closing files or releasing resources.

try:
    resource = acquire_resource()
except Exception as e:
    print(f"An error occurred: {e}")
finally:
    release_resource(resource)

Creating Custom Exceptions

class CustomError(Exception):
    """A custom exception for your application."""
 
try:
    if something_is_not_right:
        raise CustomError("Something is not right")
except CustomError as e:
    print(f"Caught our custom error: {e}")

Raising exceptions

You may want to raise an exception. Consider the following add function that only accepts numbers.

import numbers
 
def add_numbers(a, b):
    if not isinstance(a, numbers.Number) or not isinstance(b, numbers.Number):
        raise ValueError('Both arguments must be numbers')
    return a + b
 
print(add_numbers(2, 3))
print(add_numbers(2.5, 3.8))
print(add_numbers(1+2j, 3+4j))
 
try:
    print(add_numbers('a', 'b'))
except ValueError as e:
    print(e)

Conclusion

We went over a few base classes, buitlin exceptions and how we can handle them. As you saw, error messages in Python 3.12 are more descriptive.n Hopefully, we now have a better understanding of Python exceptions and how to handle them.