6  Errors & Exceptions

Author

Ryan M. Moore, PhD

Published

March 11, 2025

Modified

April 28, 2025

Errors and unexpected situations happen in nearly all programs, even simple ones. It’s important to understand that errors are a normal part of programming, and there are good techniques to help us write code that can handle common errors effectively.

This chapter explores errors and exceptions and how to manage them in your Python code. We’ll begin with basic errors like syntax mistakes (similar to grammar errors in writing) or mathematical issues like dividing by zero. Then we’ll progress to more advanced techniques that help your programs handle unexpected situations gracefully.

Throughout this guide, you’ll see practical examples that show how to:

By the end, you’ll feel more confident writing code that can recover from problems rather than crashing when something unexpected happens. This skill is essential for creating robust and reliable programs.

Syntax Errors

Syntax errors are problems that happen when you write Python code that breaks the language’s grammar rules. These errors are very common when you’re first learning Python because you’re still getting used to how Python code should be structured. Here is an example of a syntax error:

for i in range(10)
    print(i)

If you run this Python code, you would get an error message like this:

for i in range(10)
                  ^
SyntaxError: expected ':'

As you can see, I forgot to put the colon (:) at the end of the first line. This is a classic syntax mistake in Python, similar to forgetting a period at the end of a sentence.

Remember in previous tutorials and assignments, like when we discussed removing items from dictionaries (Section 2.6.2.3), scope (Section 4.5), and the “ask for forgiveness” approach (Section 5.6.1), we used try/except blocks to “catch” errors. However, syntax errors cannot be caught using try/except. This is because the Python interpreter checks your code’s syntax before running any of it. The syntax error is detected during this checking phase, before your program even starts executing, so the try/except block never gets a chance to run.

Understanding Python Exceptions

Errors can still appear in your code even when the syntax is correct. In Python, these runtime errors are called exceptions. If an exception happens and you don’t address it in your code, your program will crash and stop running.

Common Exceptions

There are many different exceptions in Python. Let’s start with two simple ones: NameError and TypeError.

Name Errors

A NameError occurs when Python can’t find a name you’re trying to use. This typically happens when you try to use a variable or function that doesn’t exist or hasn’t been defined yet.

x = 1 + y

If you ran this code in the Python interpreter or from a program, you would see an error message like this:

NameError             Traceback (most recent call last)
File 06_errors.qmd:1
----> 1 x = 1 + y

NameError: name 'y' is not defined

This error message shows the type of error, the file where it happened, and a helpful explanation of the problem. We will go into more detail about reading error messages later in this tutorial.

Tip 6.1: Stop & Think

Look at the following code:

try:
    print(gene)
except NameError as error:
    print(f"{error=}")

What do you think will happen and why?

Type Errors

A TypeError happens when you try to perform an operation on a data type that doesn’t support that operation. (It’s sort of like trying to use a lab technique on the wrong type of sample.)

47 + "102"

Running this code produces the following error:

TypeError              Traceback (most recent call last)
File 06_errors.qmd:1
----> 1 47 + "102"

TypeError: unsupported operand type(s) for +: 'int' and 'str'

This message tells you that Python doesn’t allow you to add an integer and a string together.

Other Common Exceptions

Some other exceptions you might encounter include:

  • IndexError: When you try to access a position beyond the end of a list or string (like trying to access the 5th nucleotide in a 3-nucleotide codon)
  • FileNotFoundError: When Python can’t find the file or directory you’re trying to access
  • KeyError: When you try to access a key that doesn’t exist in a dictionary (similar to looking up a gene ID that isn’t in your database)
  • AttributeError: When you try to access features or properties that don’t exist for a particular object
  • ValueError: When you provide the right type of value but with incorrect content, such as:
    • Trying to use a square root transformation on a sample with a negative value (math.sqrt(-1))
    • Trying to convert na expression values (float("na"))

For a complete list of Python exceptions, see the Built-in Exceptions section of the Python manual.

Tip 6.2: Stop & Think

If you were writing a program to read and parse FASTA files, which type of error would occur if the file you were trying to read did not exist?

Reading Error Messages

One challenge many beginners face is understanding error messages, both how to interpret them and how to fix the problems they indicate.

As a new programmer, you’ll likely encounter different types of errors than experienced programmers do. You’ll often see syntax errors or errors from misusing Python’s language features or misunderstanding how functions and classes work. This can be particularly frustrating because these aren’t errors you might anticipate. While you might have expected a user-provided file name might not exist, you probably would not have anticipated using a built-in function incorrectly!

Learning to read error messages is an essential skill for your programming journey. In Python, error messages typically contain:

  • Error type: The kind of error that occurred (e.g., RuntimeError)
  • Traceback: The sequence of function calls that led to the error
  • File location: Where the error occurred (file name and line number)
  • Arrow pointer: Points to the specific part of code causing the error
  • Error message: A description of what went wrong

Let’s examine a few error messages to better understand their structure and meaning. We will start with a simple example:

def wibble():
    raise RuntimeError("oh no!!")


def wobble():
    wibble()


def woo():
    wobble()


woo()

If you ran this code in a Quarto notebook, you would get an error that looks like this:

RuntimeError              Traceback (most recent call last)
Cell In[2], line 13
      9 def woo():
     10     wobble()
---> 13 woo()

Cell In[2], line 10, in woo()
      9 def woo():
---> 10     wobble()

Cell In[2], line 6, in wobble()
      5 def wobble():
----> 6     wibble()

Cell In[2], line 2, in wibble()
      1 def wibble():
----> 2     raise RuntimeError("oh no!!")

RuntimeError: oh no!!

In this example, the process went like this:

  • First, the program called woo()
  • Then, inside woo(), it called wobble()
  • Next, inside wobble(), it called wibble()
  • Finally, inside wibble(), a RuntimeError occurred

You can read the traceback in two ways:

Top-to-bottom (the order the code executed):

  1. On line 13, woo() is called
  2. On line 10, in woo, wobble() is called
  3. On line 6, in wobble, wibble() is called
  4. On line 2, in wibble, raise RuntimeError is called, which crashes the program

Bottom-to-top (starting with the actual error):

  1. On line 2, in wibble, raise RuntimeError is called, which crashes the program
  2. On line 6, in wobble, wibble() is called
  3. On line 10, in woo, wobble() is called
  4. On line 13, woo() is called

Examining error tracebacks in both directions will often help you better understand what went wrong in your code.

Let’s look another example:

def parse_line(line):
    gene, sample, expression = line.strip().split(",")

    return (gene, sample, float(expression))


def read_expression_data(filename):
    with open(filename) as f:
        for line in f:
            gene, sample, expression = parse_line(line)
            print(f"{gene}-{sample} => {expression}")


read_expression_data("expression_data.csv")

Pretend there is a file called expression_data.csv that has the following contents:

gene1,sample1,25
gene1,sample2,50
gene2,sample1,na
gene2,sample2,15

Let’s assume that you have saved that code in a script called example.py. When you run it you would see output that looks something like this:

gene1-sample1 => 25.0
gene1-sample2 => 50.0
Traceback (most recent call last):
  File "example.py", line 14, in <module>
    read_expression_data("expression_data.csv")
    ~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
  File "example.py", line 10, in read_expression_data
    gene, sample, expression = parse_line(line)
                               ~~~~~~~~~~^^^^^^
  File "example.py", line 4, in parse_line
    return (gene, sample, float(expression))
                          ~~~~~^^^^^^^^^^^^
ValueError: could not convert string to float: 'na'

Let’s break it down:

The script processes gene expression data from a CSV file. It runs smoothly for the first two lines of the file, but crashes when it encounters "na" in the third line. The error happens because Python’s float() function cannot convert the text "na" (which stands for “not available” in biological data) into a floating-point number.

The error message shows us the exact path of execution that led to the problem. It starts at line 14 where we call read_expression_data(), then moves to line 10 where we call parse_line(), and finally reaches line 4 where the actual error occurs when trying to convert "na" to a float.

This is a common issue when working with biological datasets, which often contain missing values represented as “na”, “N/A”, or similar placeholders. To fix this problem, we would need to add error handling when running the float function, or use some other technique to check for these special values before attempting the conversion.

Tip 6.3: Stop & Think

In the above example, can you think of a better way to handle the na value rather than letting the program crash?

Basic Exception Handling

Now that we’ve discussed what exceptions are, let’s explore how we can recover from them and prevent unexpected errors from crashing our programs.

The basic format uses try: followed by an indented block of code, then except SomeError:, followed by another indented block of code:

try:
    47 + "102"
except TypeError as error:
    print(f"an type error occurred: {error=}")
an type error occurred: error=TypeError("unsupported operand type(s) for +: 'int' and 'str'")

Let’s break this down:

  • The “try clause” contains the code that might cause an error.
    • It is placed between the try and except keywords.
    • Here, our try clause has just one expression: 47 + "102"
  • The “except clause” contains the code that runs if an error occurs.
    • It is placed after the except keyword and continues until the indentation ends.
  • except TypeError as error:
    • TypeError specifies which kind of error we want to catch
    • If a TypeError happens, Python saves the error information in the variable named error so we can use it
  • print(f"a TypeError occurred: {error}") is the code that runs when a TypeError occurs in the try clause

The try/except statement is a bit like a safety net for your code: you’re trying something that might fail (the try clause), but you’ve prepared a backup plan (the except clause) just in case.

Next, let’s see how Python runs through this type of code.

Try/Except Code Flow

Python follows a specific process when it encounters a try/except block. First, it attempts to run all the code inside the “try clause.” If this code runs without any problems, Python simply skips the except clause and continues with the rest of your program.

However, if an error occurs while running the try clause, Python immediately stops executing that section. It then checks if the error type matches what you specified after the except keyword. For instance, if you wrote except TypeError:, Python looks specifically for TypeErrors.

If the error matches what you specified, Python runs the code in the except clause and then continues with the rest of your program.

If the error doesn’t match what you specified, Python considers it an “unhandled exception”. In this case, your program will stop running and display an error message.

This might sound a bit abstract, so let’s go through some examples to see how it works step by step.

No Exception in the Try Clause

In this example, no exception occurs in the try clause.

try:
    print("hi")
except TypeError as error:
    print(f"an error occurred: {error=}")

print("yo!")
hi
yo!
  1. Python runs the contents of the try clause: print("hi")
  2. print("hi") runs without error and displays “hi” on the screen
  3. Since no error occurred, Python skips the except clause completely
  4. Python continues to the next line and runs print("yo!")

Exception in the Try Clause

In this example, an error occurs that matches the one in our except clause:

try:
    print(greeting)
except NameError as error:
    print(f"an error occurred: {error=}")

print("yo!")
an error occurred: error=NameError("name 'greeting' is not defined")
yo!
  1. Python runs the contents of the try clause: print(greeting)
  2. print(greeting) causes a NameError because greeting hasn’t been defined (you must define variables before using them)
  3. Since a NameError occurred and we specifically included NameError in our except statement, Python executes the except block: print(f"an error occurred: {error=}")
  4. After completing the try/except block, Python continues to the next line and runs print("yo!")

Non-Matching Exception in the Try Clause

This example shows what happens when the error that occurs is different from the one we’re trying to catch:

try:
    47 + "102"
except NameError as error:
    print(f"an error occurred: {error=}")

print("yo!")
  1. Python runs the contents of the try clause: 47 + "102"
  2. 47 + "102" causes a TypeError because Python can’t add a number to text
  3. Python checks if TypeError matches what we’re catching in our except statement, but we’re only catching NameError
  4. Since the error types don’t match and there are no other try statements, the error remains uncaught
  5. The program crashes with an error message and stops running
  6. print("yo!") never runs because the program already crashed

Catching Multiple Exceptions

You can catch different types of exceptions within a single try/except block by adding multiple except clauses. When an error occurs in the try block, Python looks for a matching except block to handle that specific error type. Once it finds a match, it runs that code and then continues with the rest of your program. If no match is found, then the program crashes.

For example, if your code causes a NameError (like using a variable that doesn’t exist), only the except clause that handles NameError will run. The other except clauses, like one for TypeError, will be skipped:

try:
    print(greeting)
except NameError as name_error:
    print(f"a NameError occurred: {name_error=}")
except TypeError as type_error:
    print(f"a TypeError occurred: {type_error=}")
a NameError occurred: name_error=NameError("name 'greeting' is not defined")

In this case, it is the opposite – the code in the try clause causes a TypeError, causing only the expect clause handling type errors to run:

try:
    47 + "102"
except NameError as name_error:
    print(f"a NameError occurred: {name_error}")
except TypeError as type_error:
    print(f"a TypeError occurred: {type_error}")
a TypeError occurred: unsupported operand type(s) for +: 'int' and 'str'

Sometimes you might want to handle different errors in the same way. In these cases, you can group multiple exceptions in a single except clause:

try:
    print(greeting)
except (NameError, TypeError) as error:
    print(f"an error occurred: {error=}")

try:
    47 + "102"
except (NameError, TypeError) as error:
    print(f"an error occurred: {error=}")
an error occurred: error=NameError("name 'greeting' is not defined")
an error occurred: error=TypeError("unsupported operand type(s) for +: 'int' and 'str'")

Clauses Can Contain Multiple Statements

Each section (clause) in a try/except block can include multiple lines of code:

try:
    # This line will run successfully
    print(1 + 2)

    # This line will cause a TypeError (mixing number and text)
    print(10 + "20")

    # This line will never execute because the error above stops the try block
    print(100 + 200)
except TypeError as error:
    # This line runs because we caught a TypeError from above
    print(f"an error occurred: {error=}")

    # This line also runs since all code in the except clause executes
    # (unless another error happens)
    print("this will also run!")
3
an error occurred: error=TypeError("unsupported operand type(s) for +: 'int' and 'str'")
this will also run!

Note: We’ll discuss this more later, but it’s generally best practice to keep the code in each clause short and simple, with as few statements as possible.

Exceptions Can Happen in the Except Block

One important thing to remember is that exceptions can also occur in your except block code:

try:
    # This line will run
    print(1 + 2)

    # This line will raise the TypeError
    print(10 + "20")

    # This line will not run because the previous line caused a TypeError
    print(100 + 200)
except TypeError as error:
    # This line will run because a TypeError occurred in the above clause
    print(f"an occurred: {error=}")

    # This line will raise a NameError since the name `twenty` has not been
    # defined
    print(10 + twenty)

    # This line will not run because the above line raises another exception!
    print("this will also run!")

If you ran this code, you would see output like this:

TypeError                                 Traceback (most recent call last)
Cell In[1], line 6
      5 # This line will raise the TypeError
----> 6 print(10 + "20")
      8 # This line will not run because the previous line caused a TypeError

TypeError: unsupported operand type(s) for +: 'int' and 'str'

During handling of the above exception, another exception occurred:

NameError                                 Traceback (most recent call last)
Cell In[1], line 16
     12 print(f"an occurred: {error=}")
     14 # This line will raise a NameError since the name `twenty` has not been
     15 # defined
---> 16 print(10 + twenty)
     18 # This line will not run because the above line raises another exception!
     19 print("this will also run!")

NameError: name 'twenty' is not defined

Notice the key message: “During handling of the above exception, another exception occurred”. This tells us what happened. While the program was trying to recover from one error, it encountered a second error. Since there was no error handler set up for this second error, the program crashed.

For more information about this concept, check out the Python documentation on exception chaining.

Tip 6.4: Stop & Think

If you’re developing a program to analyze biological sequencing data, can you think of a reason why allowing exceptions to occur within an except block might lead to tricky issues in your code?

Advanced Exception Handling

Let’s explore some more advanced ways to handle exceptions in Python. While you might not need to use these techniques in your current assignments and miniprojects, it’s still valuable to understand them. You’ll encounter these patterns when reading other people’s code or working with existing Python libraries and tools.

Exception Hierarchies

Python exceptions form a hierarchy, and are organized a bit like a family tree. Here’s a simplified view of this hierarchy:

BaseException
 └── Exception
      ├── ArithmeticError
      │    ├── FloatingPointError
      │    ├── OverflowError
      │    └── ZeroDivisionError
      ├── RuntimeError
      │    ├── NotImplementedError
      │    ├── PythonFinalizationError
      │    └── RecursionError

To understand this relationship:

  • A ZeroDivisionError is a type of ArithmeticError
  • An ArithmeticError is a type of Exception
  • An Exception is a type of BaseException

This is similar to how taxonomic relationships work. Just as a cat is a feline, all felines are mammals, and all mammals are animals, a ZeroDivisionError is an ArithmeticError, all ArithmeticErrors are Exceptions, and all Exceptions are BaseExceptions.

Note that a ZeroDivisionError is an ArithmeticError, but it is not a RuntimeError – similar to how cats and dogs are both mammals, but a cat is not a canine.

The complete exception hierarchy is is available in the Python documentation.

The benefit of this hierarchy is that we can catch a whole group of related errors without listing each one individually. Here’s a simple example with a function that performs basic math operations:

import math

def silly_math(x, y):
    math.exp(x) / y

This function can raise several different errors:

  • If y is 0, we get a ZeroDivisionError
  • If x is too large, we get an OverflowError
  • If x or y aren’t numbers, we get a TypeError

Imagine we’re reading the values of x and y from a data file, so we don’t know what they’ll be until the program runs. We can handle potential errors like this:

# ZeroDivisionError
try:
    x = 1
    y = 0
    silly_math(x, y)
except (ZeroDivisionError, OverflowError) as error:
    print(f"there was an arithmetic error! {error=}")
except TypeError as error:
    print(f"one of the values wasn't numeric! {error=}")


# OverflowError
try:
    x = 1000
    y = 2
    silly_math(x, y)
except (ZeroDivisionError, OverflowError) as error:
    print(f"there was an arithmetic error! {error=}")
except TypeError as error:
    print(f"one of the values wasn't numeric! {error=}")


# TypeError
try:
    x = 1
    y = "2"
    silly_math(x, y)
except (ZeroDivisionError, OverflowError) as error:
    print(f"there was an arithmetic error! {error=}")
except TypeError as error:
    print(f"one of the values wasn't numeric! {error=}")
there was an arithmetic error! error=ZeroDivisionError('float division by zero')
there was an arithmetic error! error=OverflowError('math range error')
one of the values wasn't numeric! error=TypeError("unsupported operand type(s) for /: 'float' and 'str'")

This works, but we can simplify by using the parent class ArithmeticError instead:

# ZeroDivisionError
try:
    x = 1
    y = 0
    silly_math(x, y)
except ArithmeticError as error:
    print(f"there was an arithmetic error! {error=}")
except TypeError as error:
    print(f"one of the values wasn't numeric! {error=}")


# OverflowError
try:
    x = 1000
    y = 2
    silly_math(x, y)
except ArithmeticError as error:
    print(f"there was an arithmetic error! {error=}")
except TypeError as error:
    print(f"one of the values wasn't numeric! {error=}")
there was an arithmetic error! error=ZeroDivisionError('float division by zero')
there was an arithmetic error! error=OverflowError('math range error')

Both ZeroDivisionError and OverflowError are caught by the ArithmeticError handler because they are both types of ArithmeticError.

However, it’s important to understand that this doesn’t work in reverse. Let’s see what happens if we try to catch an ArithmeticError with a ZeroDivisionError handler:

try:
    # This code cause a specific error, in this case an ArithmeticError,
    # to happen
    raise ArithmeticError("oops!")
except ZeroDivisionError:
    print("this won't catch the ArithmeticError")

Here is the error message:

ArithmeticError                  Traceback (most recent call last)
Cell In[1], line 2
      1 try:
----> 2     raise ArithmeticError("oops!")
      3 except ZeroDivisionError:
      4     print("this won't catch the ArithmeticError")

ArithmeticError: oops!

This is because while every ZeroDivisionError is an ArithmeticError, every ArithmeticError is not a ZeroDivisionError.

Note: check out the raise statement for more about manually raising exceptions.

You may have noticed that this connects back to the Object-Oriented Programming concepts we discussed in Section 5.1. This error class hierarchy shows inheritance at work: exception classes inherit from their parent exception classes. When we catch an ArithmeticError, we’re using this inheritance relationship to handle any type of exception that belongs to that family. This is a practical example of why inheritance is useful in programming.

Tip 6.5: Stop & Think

Consider a function that reads a FASTA file. What exception types might you want to catch, and which parent exception class could catch all of them?

Nesting Try/Except Blocks

It can sometimes be useful to nest try/except blocks. This is like having backup plans for your backup plans!

The first example shows how Python searches for an appropriate error handler. If an inner error handler doesn’t match the exception type, Python will check outer handlers:

try:
    try:
        raise TypeError("oh no!")
    except NameError:
        print("this won't print because it's trying to catch a NameError")
except TypeError:
    print("caught a TypeError")
caught a TypeError

In this case, the inner handler is looking for a NameError, but we raised a TypeError. Since the inner handler can’t catch it, Python checks the outer handler, which successfully catches the TypeError.

Our second example is different. Here, the inner handler does catch the TypeError, but then it raises a new NameError:

try:
    try:
        raise TypeError("oh no!")
    except TypeError:
        print("we caught a TypeError")

        raise NameError("here is a name error")

        print("this will not print!")
except NameError:
    print("caught a NameError")
we caught a TypeError
caught a NameError

The inner handler catches the TypeError but then creates a new problem by raising a NameError. Fortunately, the outer handler catches this new error, preventing our program from crashing.

Using else and finally

When working with try/except blocks, we can add two special clauses that give us more control over our code: else and finally.

The else clause lets us run code only if no errors occurred in the try block. As in, “try this code, and if it works without errors, do this extra step.”

The finally clause runs its code regardless of whether an error happened or not. It’s like saying “no matter what happens, always do this cleanup step.” This is a good choice for tasks that need to happen even if errors occur like closing files or database connections.

The else clause is useful for several reasons:

  • It keeps your error handling code separate from your normal processing code
  • It ensures certain operations only happen when everything works correctly
  • It prevents catching unrelated errors that might occur in your processing code

While the else clause isn’t very common in Python code, it serves specific purposes. One important use is running additional code before any finalization steps without including it in the try block itself. Without using else, you’d have to put this code in the try block, which means unintended errors might get caught and handled incorrectly.

Unlike finally (which always runs), the else clause only runs when the try block succeeds completely. This makes it useful for operations that should only happen when everything works as expected.

Here’s a simple example showing how else can make your code cleaner:

def example(): ...
def handle_failure(error): ...
def handle_success(result): ...

try:
    result = example()
    # Make a boolean flag that says we were successful
    success = True
except MagicError as error:
    handle_failure(error)

# We only want to run this on success, so it must be behind a flag
if success:
    handle_success(result)


# With else -- cleaner approach
try:
    result = example()
except MagicError as error:
    handle_failure(error)
else:
    handle_success(result)

Let’s try an example that uses all of the clauses: try, except, else, and finally. This simple example shows how to handle different situations when searching a protein database. The different parts handle specific situations:

def find_protein(protein_database, entry):
    try:
        # Try to find the protein in the database.
        info = protein_database[entry]
    except KeyError:
        # If it is not found, log an error.
        print(f"Entry '{entry}' not found in the database")
    else:
        # If it is found, return the info
        return info
    finally:
        # Regardless of success or failure, log a message saying you checked for the entry.
        print(f"Search completed for entry {entry}")


# Example usage
protein_database = {
    "P00452": {
        "gene": "nrdA",
        "protein": "Ribonucleoside-diphosphate reductase 1 subunit alpha",
    },
    "P00582": {"gene": "polA", "protein": "DNA polymerase I"},
}

entries = ["P00582", "P19822", "P00452"]
for entry in entries:
    # If the entry is found, return the info, if not, it will return None
    info = find_protein(protein_database, entry)

    # Since this might be none, we have to check that it exists before working
    # with it.
    if info:
        print(info["gene"], info["protein"], sep=" -> ")

    print()
Search completed for entry P00582
polA -> DNA polymerase I

Entry 'P19822' not found in the database
Search completed for entry P19822

Search completed for entry P00452
nrdA -> Ribonucleoside-diphosphate reductase 1 subunit alpha

Here is a flowchart to help you visualize how the logic flows through the try/except/else/finally code structure.

try/except/else/finally flowchart

try/except/else/finally flowchart
Tip 6.6: Stop & Think

Can you think of any scenarios in which the finally clause would be useful when working with biological data or analysis?

Combining Techniques

Let’s take a look at an example that combines a few of the techniques we have talked about so far. Here we’re trying to read a file, convert each line to an integer, and store those integers in a list. Our code is structured with a try clause followed by several except clauses to handle different types of errors:

def read_integers(file_name):
    """Read a file and convert each line to an integer."""
    with open(file_name) as file:
        return [int(line.strip()) for line in file]


try:
    read_integers("the_best_numbers.txt")
# Dealing with files can cause errors in the OSError family
except OSError as error:
    print(f"an error reading the file occurred: {error=}")
# Trying to a convert a string to an integer can fail too
except ValueError as error:
    print(f"could not convert line to an integer: {error=}")
# This is a "catch-all" clause, since we want to log any unexpected errors
except Exception as error:
    print(f"Unexpected error: {error=}")
    # Since it's not good practice to handle errors we are not expecting,
    # we reraise the error and then the caller of this code can handle it
    # how they want.
    raise
an error reading the file occurred: error=FileNotFoundError(2, 'No such file or directory')

The first except clause catches any OSError. These errors happen when something goes wrong with the file system, like if "the_best_numbers.txt" doesn’t exist. The OSError family includes specific errors like FileNotFoundError (when the file doesn’t exist), PermissionError (when you don’t have permission to read the file), and other file-related problems.

The second except clause catches ValueError. This happens if the int() function can’t convert a line to an integer. For instance, if a line contains “ABC” instead of a number like “123”, a ValueError will occur.

The last except clause catches any other type of Exception. This acts as a safety net for unexpected errors. When an unexpected error occurs, we:

  1. Print information about the error
  2. Re-raise the error using the raise statement

Re-raising an error means that after we handle it partially (in this case, by printing information), we pass the error up to whatever code called our function. This is useful when you want to log an error but still want the calling code to to have to deal with it because there is no way to recover at the location which the error happened (i.e., you might not have enough context to do anything about it right then and there).

Creating Custom Exceptions

Python packages and libraries often create their own custom error types. For example:

  • Biopython defines a TreeError class to alert users when something goes wrong with phylogenetic trees.
  • Pandas defines a MergeError for problems that occur when trying to merge data frames.

These custom errors make it easier for users to handle problems specific to that library. Users can then distinguish between errors from the library and errors from their own code.

Let’s look at an example. First, we’ll write code without a custom error, then improve it by adding one.

def parse_dna_string(dna_string):
    """Parse a DNA string, validating the nucleotides."""
    valid_bases = {"A", "C", "G", "T", "N"}

    for i, base in enumerate(dna_string):
        if base not in valid_bases:
            raise ValueError(f"Invalid DNA base at index {i}: '{base}'")

    return dna_string


print(parse_dna_string("ACTG"))

try:
    print(parse_dna_string("ACXG"))
except ValueError as error:
    print(f"{error=}")
ACTG
error=ValueError("Invalid DNA base at index 2: 'X'")

This works fine, but imagine you’re writing a library with many classes for different aspects of sequence analysis and file parsing. Error handling could become confusing. Creating custom errors helps users understand and handle problems more clearly.

A common approach is to define a base error class for your package, and then create specific error types that inherit from it. For example, if our package is called EasyBio, we might do this:

class EasyBioError(Exception):
    """Base class for all EasyBio package errors."""
    pass

class InvalidBaseError(EasyBioError):
    """Error raised when a DNA sequence contains invalid characters"""
    pass

# We could define more specific errors for other situations too

def parse_dna_string(dna_string):
    valid_bases = {"A", "C", "G", "T", "N"}

    for i, base in enumerate(dna_string):
        if base not in valid_bases:
            raise InvalidBaseError(f"Invalid DNA base at index {i}: '{base}'")

    return dna_string


print(parse_dna_string("ACTG"))

try:
    print(parse_dna_string("ACXG"))
except InvalidBaseError as error:
    print(f"{error=}")
ACTG
error=InvalidBaseError("Invalid DNA base at index 2: 'X'")

This code is more descriptive about what went wrong - specifically that we found an invalid nucleotide. The benefits of this approach become clearer in larger packages with many different types of potential errors.

Benefits of using custom exceptions include:

  • Clear hierarchy: Users can catch just the base exception (EasyBioError) to handle any error from your library, or catch specific exceptions for targeted handling.
  • Improved error messaging: Custom exceptions can include field-specific information that helps users understand what went wrong in their context.
  • Documentation: Custom exceptions serve as self-documenting code, showing users what can go wrong.

Users of your code will often expect to see custom error types that are specific to your package or library. This approach lets you control which errors users need to handle and gives them clear information about what went wrong.

Tip 6.7: Stop & Think

If you were creating a package for RNA-seq analysis, what custom exception types might be useful to define?

General Tips

Now that we’ve explored errors and exceptions, let’s go through some general tips about how to deal with them.

Use Descriptive Error Messages

It’s important to include helpful details when raising errors. This makes your code more robust and provides valuable context to anyone using your functions.

Be specific and descriptive:

# Too vague
raise ValueError("Invalid input")

# More descriptive
raise ValueError("Expected DNA sequence but found invalid characters")

# Shows the specific problems with the sequence
raise ValueError(f"Expected DNA sequence but found invalid characters at positions {invalid_positions}")

Include relevant values:

if start >= end:
    raise ValueError(f"Start position ({start}) must be less than end position ({end})")

Suggest solutions when possible:

if not os.path.exists(filepath):
    raise FileNotFoundError(f"File '{filepath}' not found. Check spelling or use absolute path.")

# Not as good:
raise ConnectionError("connection failed")

# Better
raise ConnectionError("Database connection failed: check that server is running on port 5432")

Document the exceptions your functions can raise:

def parse_fasta(filename):
    """
    Parse a FASTA file and return sequences.

    Args:
        filename: Path to the FASTA file

    Returns:
        List of (header, sequence) tuples

    Raises:
        FileNotFoundError: If the file doesn't exist
        ValueError: If the file is not in valid FASTA format
    """
    pass

These suggestions will make debugging easier for both you and anyone using your code.

When to Catch Exceptions

Let’s talk about you should catch exceptions and when it’s better to let them bubble up through your program.

You should catch exceptions when:

  • You can recover meaningfully
    • You can provide a default value if an error occurs (for example, when calculating fold change and dividing by zero, just return one)
    • You can safely skip problematic data (for example, when processing multiple sequences from a FASTA file and one is corrupted, just skip that sequence)
  • You need to clean up resources (for example, close a database connection if a query fails)
  • You want to translate an error into a different, more appropriate error type

You should not catch exceptions when:

  • There is no reasonable way to recover from the error
    • If you have no recovery strategy, let the exception move up to a level that can handle it
    • For example:
      • When a user provides a file name that doesn’t exist
      • When a database connection fails
  • There is a cleaner alternative (for example, using dict.get to provide a default value instead of catching a KeyError)

Decision Tree for Catching Exceptions

Decision Tree for Catching Exceptions

Other tips

  • When you decide to catch an exception, catch it as specifically as possible
    • Prefer catching specific exceptions like ValueError or FileNotFoundError rather than broad ones like Exception
    • This prevents accidentally hiding bugs by catching exceptions you weren’t expecting
  • Don’t silently ignore exceptions
    • At minimum, you should at least log a message to let users know something went wrong

Example

Here is a tiny example that breaks pretty much all the suggestions that we have given:

def example(x, y, z):
    try:
        return potentially_risky_function(x, y, z)
    except Exception:
        pass

This is better though!

def example(x, y, z):
    try:
        return potentially_risky_function(x, y, z)
    except ValueError as e:
        print(f"WARNING -- Invalid value encountered: {e}")
        return default_value
    except IOError as e:
        print(f"ERROR -- IO error: {e}")
        # Re-raise errors we can't handle
        raise

Summary

In this tutorial, we covered Python exceptions and how to handle them.

  • Syntax errors happen when your code breaks Python’s grammar rules. These must be fixed before your code can run.
  • Exceptions occur during program execution when something unexpected happens, like trying to divide by zero.
  • try/except blocks let you catch exceptions and handle them smoothly, similar to how you might have contingency plans in an experiment.
  • The exception hierarchy allows you to catch specific error types (like FileNotFoundError) or broader categories of errors (like OSError).
  • else and finally clauses give you extra control in error handling, letting you run code when no errors occur or ensure cleanup happens regardless.
  • Custom exceptions help you write more readable and maintainable code by creating error types specific to your program.

Error handling is a crucial skill for building robust programs that can recover gracefully when things go wrong. Practice identifying where your code might fail (like when reading files or processing data) and implement appropriate exception handling to build more reliable applications for your research.

Suggested Readings

You might enjoy checking out some of these resources:

Practice Problems

6.1

Write a try/except block that attempts to convert a string to a float, but catches the ValueError if the string isn’t a valid number. When an error occurs, it should print “Not a valid number”.

6.2

Modify this code to catch the potential error:

counts = {"A": 1, "C": 2, "G": 0, "T": 4}
total = sum(counts.values())
n_ratio = counts["N"] / total 

6.3

Here is a tiny, misbehaving Python function:

import random

def silly_divide(x, y):
    if random.random() < 0.25:
        raise Exception("oops!")

    return x / y

About 75% of the time, it divides two numbers. However, the other 25% of the time, it raises an Exception.

Write code that runs this function inside a try/except block. It should have two except clauses, one to catch the ZeroDivisionError and one to catch the potential Exception. You should give the user info about the error that was caught.

6.4

Write a function called fold_change that takes two expression values and calculates their fold change. Make sure to handle any potential errors that could occur. If there is an error, the function should return None.

6.5

Create a custom exception called SequenceLengthError that inherits from Exception. Then use it in a function called validate_sequence_length that raises a SequenceLengthError if the given sequence length is not between 50 and 150 bases.

6.6

Consider the following code:

def run_simulation(max_turns):
    if max_turns < 1:
        raise ValueError("bad input")
    
    if max_turns > 1000:
        raise ValueError("bad input")
    
    # Simulation code would follow
    pass

Rewrite this function so that it provides the uses with better error messages.