Python Exception Handling: Patterns and Best Practices

When it comes to raising exceptions and exception handling in Python, I've often found myself pondering, "Should I re-raise this exception? Or maybe I should raise it from another exception?"

You see, the thing is, there are a bunch of ways to handle exceptions in Python. We’d often just wing it without really grasping the why and when to use these patterns.

In this little exploration, we’re going to uncover the differences behind these different exception-handling patterns with code examples. Today, we'll be unraveling:

  • When to catch and re-raise an exception?
  • When to raise a new exception?
  • When to chain exception?
  • When to avoid using each of the above?

Enough talk, let’s dive right into these exception-raising dilemmas and turn them into informed decisions. As we go through the examples, feel free to copy and paste the code to try it out yourself!

Pattern 1: Catch and Re-Raise Exception

def divide(x=1, y=0):
    try:
        return x / y

    except ZeroDivisionError as e:
        raise e # NOTE: this is almost equivalent to bare `raise`
Running divide() gives you a traceback to return x / y, exactly where the error was from

In this pattern, if an exception occurs during the division operation (e.g., division by zero), the original exception will be re-raised with its traceback.

As a result, the original exception is propagated all the way up the call stack with its original traceback.

💡
What is Python traceback? Well, it is essentially a record of what the program was doing before it crashed. Tracebacks are particularly useful for debugging.

Use Case

Generally speaking, this is often a good default pattern for its clarity and preservation of original traceback.

Why? Preserving traceback information is often considered a good practice as it helps in diagnosing errors effectively and understanding the sequence of events that led to the exception.

In short, this pattern is used when you want to catch a specific exception (ZeroDivisionError in this case), do something, and then re-raise the exception. It allows you to perform some specific actions before propagating the exception further.

Effectively the same as not having the try block at all

def divide(x=1, y=0):
    return x / y

Wait what?! Then why do we need the try block then?

Knowing this, the try block in our previous example might suddenly seem redundant. However, you can imagine scenarios where additional logging or error-handling logic would make the try block very useful:

def divide(x=1, y=0):
    try:
        return x / y

    except ZeroDivisionError as e:
        print("An error occurred while performing the division.")
        # Log the error, send notifications, retries etc.
        raise e

Having that said, if you do not intend to do any additional stuff, feel free to omit the try block.

Avoid

While this is generally a useful pattern, there can be scenarios where it might not be the best choice.

For instance, you may want to avoid using this pattern when you want to hide sensitive information or when the traceback contains sensitive data that you don't want to expose. Here’s an example:

try:
    user = login(email="[email protected]",password="12345")
    # Perform some sensitive operations

except InvalidPasswordFormatError as e:
    raise e
In practice, you want to be as ambiguous as possible

In this example, if an exception occurs during the sensitive operation, the original exception is re-raised with its traceback. This pattern may expose sensitive information, such as login failure information in the traceback.

This is often referred to as "leaking" or "revealing" information. Exposing detailed login failure information is bad because it aids attackers. They can exploit specifics to guess usernames and launch targeted attacks.

To prevent this, it's better to handle the exception without re-raising it or to raise a new exception with a more generalized error message (see Pattern 3).

Pattern 2: Raise New Exception

def divide(x=1, y=0):
    try:
        return x / y

    except ZeroDivisionError:
        raise ValueError("Pattern 2 error.")
Compared to Pattern 1, you should see two "Traceback (most recent call last)" here

In this example, a new exception is raised with a custom message, while preserving the original exception's traceback. If a ZeroDivisionError occurs, a new ValueError is raised with a custom message.

The traceback will include both the ZeroDivisionError and the ValueError that was raised.

Use Case

This pattern is useful when you want to raise a different (more meaningful) type of exception to indicate a specific error condition. This still allows us to preserve the original exception’s traceback.

Avoid

Avoid using this when you need to preserve the original exception.

Pattern 3: Raise New Exception from None

def divide(x=1, y=0):
    try:
        return x / y

    except ZeroDivisionError:
        raise ValueError("Pattern 3 error.") from None
You will not see return x / y mentioned in the traceback

This pattern is similar to Pattern 2. But, using from None suppresses the original ZeroDivisionError exception.

Here, the traceback will not include the original ZeroDivisionError, only the ValueError exception and the custom error message raised.

Use Case

Similar to Pattern 2, you would want to use this pattern when you want to raise a new exception with a custom message.

The difference here is that this will not include the traceback of the original exception. It is useful when you want to hide the details of the original exception from the user.

More Examples

If you are wrapping a library that throws internal exceptions and you want to present transformed external exceptions to your application users.

In this scenario, using this pattern is a suitable approach. Here’s a simple example:

try:
    # Some library code that might raise an internal exception
    result = library_function(data)

except InternalException as e:
    raise ExternalException("An error has occurred") from None

In this example, wrapping internal exceptions with external exceptions helps isolate your application code from the specifics of the internal library's implementation. This can come in handy when the users of your code don't need to understand or handle the internal exceptions thrown by the library.

Avoid

Avoid using this approach when you (or your users) need to understand the full context of where the original exception occurred and how it led to the new exception.

Suppressing exceptions can make it more difficult to track down the root cause of an error, so it should only be done when necessary.

Pattern 4: Chaining Exception

def divide(x=1, y=0):
    try:
        return x / y

    except ZeroDivisionError as e:
        raise ValueError("Pattern 4 error.") from e

Again, the ZeroDivisionError exception is caught and a new ValueError exception is raised with a custom message.

Though, the from e clause tells Python to pass the original ZeroDivisionError exception as an argument to the new ValueError exception.  As a result:

  • This allows the caller of the divide() function to know what the original error was
  • The traceback of the original exception (e) will be included in the printed traceback of the newly raised exception
💡
Read more about Exception context.

Use Case

This pattern is commonly used when you want to raise a new exception with a custom message and include the traceback of the original exception as its cause. It is useful when you want to provide both the specific error message and the context of the original exception.

In terms of best practices – it is generally recommended to use the from e syntax when raising a new exception from an inner except block. This allows us to preserve the stack trace of the original error, which (again) can be helpful for debugging.

Pattern 2 vs. Pattern 4

"What's the difference between Pattern 2 vs. Pattern 4 then? They seem awfully similar!"

In Pattern 2, the ZeroDivisionError exception is simply raised (from the line return x / y) without being handled. This means that the caller of the divide() function will not be aware of the error.

In comparison, Pattern 4 is more informative and therefore the better choice in most cases. However, Pattern 2 may be used if the caller of the divide() function does not need to know about the original error.

More Example

You're building a file-processing application that uses an external library for reading and processing files. If the library raises an internal FileNotFoundError, you want to raise your own custom exception to provide more context and information to the user.

class FileProcessingError(Exception):
    def __init__(self, message):
        super().__init__(message)

def process_file(file_path):
    try:
        content = read_file(file_path)
        # Process content...

    except FileNotFoundError as e:
        raise FileProcessingError("Unable to process file.") from e

def read_file(file_path):
    # Simulate a file not found error
    raise FileNotFoundError(f"File not found: {file_path}")

try:
    process_file("example.txt")
except FileProcessingError as e:
    print(f"Error: {e}") # Error: Unable to process file.
    print("Original Exception:", e.__cause__) # Original Exception: File not found: example.txt
Try removing the from e

In this example, the FileProcessingError is raised with the context of the original FileNotFoundError. This provides more information to the user and helps in debugging by maintaining the traceback chain.

Avoid

Avoid using this pattern when you want to hide the details of the original exception or when the original traceback is not needed (see Pattern 3) to understand the higher-level error.

In some cases, preserving both tracebacks can be confusing if not handled carefully.

Summary

Exception handling in Python is about dealing with errors in your code. The best way to handle exceptions often depends on what you want to achieve.

Anyway, here’s a TL;DR of what we went through:

  • Pattern 1 (good default): Re-raises the same exception with its original traceback.
  • Pattern 2 (situational): Re-raises a new exception, does not lose original traceback.
  • Pattern 3 (situational): Re-raises a new exception with a chained exception relationship (from None), but loses the original traceback.
  • Pattern 4 (best): Re-raises a new exception with a chained exception relationship (from e), including both the new and the original traceback.

Best Practices

  1. Use the from e syntax when raising a new exception from an inner except block. This allows us to preserve the stack trace of the original error.
  2. Do not suppress exceptions unless it is absolutely necessary. Suppressing exceptions can make it more difficult to track down the root cause of an error.
  3. Use meaningful error messages. The error message should be clear and concise, and it should provide enough information to help the user understand what went wrong.
  4. Handle all (possible) errors. It is important to handle all possible errors that your code can throw. This will help to prevent your code from crashing unexpectedly.

Remember, the way you handle exceptions should make your code easy to understand and debug. Always think about what helps you and others know what went wrong and why.

Besides learning the right way to handle exceptions, it's just as important to stop using exceptions like this in Python!

Hosted on Digital Ocean.