A guide to exception handling in Python
Tags: python
Published: 2024-02-15 18:46:43

*This article was originally written by [Muhammed Ali](https://www.honeybadger.io/blog/authors/muhammedali/) on the [Honeybadger Developer Blog](https://www.honeybadger.io/blog/a-guide-to-exception-handling-in-python/).*

Exceptions can occur for various reasons, such as invalid input, logical errors, file handling issues, network problems, or other exception conditions. Examples of exceptions in Python include `ZeroDivisionError`, `TypeError`, `FileNotFoundError`, and `ValueError`, among others. Exception handling is a crucial aspect of writing robust and reliable code in Python.

This tutorial aims to provide a comprehensive understanding of exception-handling techniques and provide examples of how those techniques can be used practically.

## What is an exception?

In Python, an exception is an event that occurs during the execution of a program and disrupts the normal flow of the program. It represents an error or an exception condition that the program encounters and cannot handle by itself.

When an exception occurs, it is "raised" or "thrown" by the Python interpreter. The exception then propagates up the call stack, searching for an exception handler that can catch and handle the exception. If no suitable exception handler is found, the program terminates, and an error message is displayed.

Here's an example of a `ZeroDivisionError` exception being raised and handled using a `try-except` block:

```python
try:
    result = 10 / 0  # Raises ZeroDivisionError
except ZeroDivisionError:
    print("Error: Division by zero!")
```

In this example, the code within the `try` block raises a `ZeroDivisionError` exception when attempting to divide by zero. The exception is caught by the `except` block, and the specified error message is printed, allowing the program to continue execution instead of abruptly terminating.

## Nested Try-Except Blocks

Nested try-except blocks provide a way to handle specific exceptions at different levels of code execution. This technique allows you to catch and handle exceptions more precisely based on the context in which they occur. Consider the following example:

```python
try:
    # Outer try block
    try:
        # Inner try block
        file = open("nonexistent_file.txt", "r")
        content = file.read()
        file.close()
        print("File content:", content)
    except FileNotFoundError:
        print("Error: File not found!")
except:
    print("Error: Outer exception occurred!")

```

Output:

```text
Error: File not found!
```

In this example, the inner try block attempts to open a file "nonexistent_file.txt" in read mode, which doesn't exist and raises a `FileNotFoundError`. The exception is caught by the inner except block, which prints the error message "Error: File not found!".

Since the exception is handled within the inner except block, the outer except block is not executed. However, if the inner except block was not executed, the exception would propagate to the outer except block, and the code within the outer except block would be executed.

## Catching and Re-Raising Exceptions

Catching and re-raising exceptions is a useful technique when you need to handle an exception at a specific level of code execution, perform certain actions, and then allow the exception to reproduce to higher levels for further handling. Let's explore the example further and discuss its significance.

In the provided code snippet, the `validate_age` function takes an `age` parameter and checks if it is negative. If the age is negative, a `ValueError` is raised using the `raise` keyword. The exception is then caught by the except block that specifies `ValueError` as the exception type.

```python
def validate_age(age):
    try:
        if age < 0:
            raise ValueError("Age cannot be negative!")
    except ValueError as ve:
        print("Error:", ve)
        raise  # Re-raise the exception

try:
    validate_age(-5)
except ValueError:
    print("Caught the re-raised exception!")
```

In this case, if the age provided to `validate_age` is -5, the condition `if age < 0` is satisfied, and a `ValueError` is raised with the message "Age cannot be negative!".

The except block then catches the `ValueError` and prints the error message using `print("Error:", ve)`. This step allows you to perform specific actions, such as logging the error or displaying a user-friendly error message.

After printing the error message, the `raise` statement is used to re-raise the caught exception. This re-raised exception propagates to a higher level of code execution, allowing it to be caught by an outer exception handler if present.

The output of this code snippet is:

```text
Error: Age cannot be negative!
Caught the re-raised exception!
```

This example demonstrates the importance of catching and re-raising exceptions. By catching an exception, performing necessary actions, and re-raising it, you have more control over how the exception is handled at different levels of your code.

## Handling Exceptions in Loops

Handling exceptions in loops is essential to ensure the smooth execution of code and prevent premature termination. By incorporating exception handling within loops, you can gracefully handle specific exceptions and continue the loop iteration. Let's explore the example further to understand its significance.

```python
numbers = [1, 2, 3, 0, 4, 5]

for num in numbers:
    try:
        result = 10 / num
        print(f"Result: {result}")
    except ZeroDivisionError:
        print("Error: Division by zero!")
    except Exception as e:
        print("Unknown Error:", e)
```

In this code snippet, we have a loop that iterates over a list of numbers. For each number, a division operation is performed by dividing `10` by the current number.

If the number is zero, a `ZeroDivisionError` is raised. The first `except ZeroDivisionError` block catches this exception and prints the error message "Error: Division by zero!". Additionally, there is a generic `except Exception as e` block to catch any other exceptions that may occur during the loop iteration.

The output of this code snippet is:

```bash
Result: 10.0
Result: 5.0
Result: 3.3333333333333335
Error: Division by zero!
Result: 2.5
Result: 2.0
```

As we can see from the output, the loop successfully performs the division operation for non-zero numbers and prints the results. When encountering a zero, the `ZeroDivisionError` is caught, and the error message is printed. The loop continues to execute, handling each iteration gracefully, even in the presence of exceptions.

## Handling exceptions in asynchronous code (`asyncio`)

Handling exceptions in asynchronous code can be crucial to ensure the stability and proper functioning of asynchronous tasks. The **`asyncio`** library in Python provides tools and mechanisms to handle exceptions in async tasks effectively.

```python
import asyncio

async def divide(a, b):
    return a / b

async def main():
    try:
        await divide(10, 0)
    except ZeroDivisionError:
        print("Error: Division by zero!")

asyncio.run(main())

```

In this code snippet, we have an asynchronous function named `divide` that performs a division operation on two numbers. The division is executed using the division operator `/`, which may raise a `ZeroDivisionError` if the denominator is zero.

Within this function, we use the `try-except` block to catch any `ZeroDivisionError` that may occur during the execution of the `divide` coroutine.

The output of this code snippet is:

```bash
Error: Division by zero!
```

As expected, the division by zero raises a `ZeroDivisionError` exception, which is caught by the `except ZeroDivisionError` block. The error message "Error: Division by zero!" is then printed.
Handling exceptions in asynchronous code with `asyncio` involves using the `try-except` block within the context of an async task.

## Developing custom exceptions

Python allows developers to create custom exception classes to handle specific types of errors within their applications. Creating custom exception classes provides a way to organize and categorize errors, making code more maintainable and enabling more granular error handling. With this in mind, let's now explore the process of creating custom exception classes in Python.

To create a custom exception class, you need to define a new class that inherits from one of the built-in exception classes or the base `Exception` class. Here is an example:

```python
import logging

class CustomException(Exception):
    def __init__(self, message):
        super().__init__(message)
        self.error_code = 1001

 
    def log_error(self):
        logger = logging.getLogger("custom_logger")
        logger.error(f"Custom Exception occurred: {self}")
def divide(a, b):
    if b == 0:
        raise CustomException("Division by zero is not allowed.")
    return a / b

try:
    result = divide(10, 0)
except CustomException as ce:
    print("Error code:", ce.error_code)
    ce.log_error()

```

Output:

```bash
Error code: 1001
Custom Exception occurred: Division by zero is not allowed.
```

In the code above, the `CustomException` class is defined, inheriting from the base `Exception` class. It has an additional attribute, `error_code`, which is set to `1001` in the constructor (`__init__` method). The `super().__init__(message)` call initializes the base `Exception` class with the given error message.

The `log_error` method retrieves or creates a logger named "custom_logger" using `logging.getLogger("custom_logger")`. The `logger.error` method is used to log an error message indicating that a custom exception occurred. The `{self}` expression in the log message includes the string representation of the exception object.

The `divide` function performs division between two numbers. If the divisor (`b`) is zero, the `CustomException` is triggered with the error message "Division by zero is not allowed."

The division operation is attempted within a `try-except` block. If a `CustomException` is raised, it is caught using the `except CustomException as ce` block. The error code of the exception is printed (`print("Error code:", ce.error_code)`) to demonstrate access to the `error_code` attribute of the `CustomException` instance. Finally, the `log_error` method is invoked (`ce.log_error()`) to log the exception.

## Logging exceptions to an error monitoring service

It’s inevitable that you’ll come across errors and exceptions when developing software applications. Monitoring and managing these errors effectively is crucial for maintaining the stability and performance of an application. One effective approach is to log exceptions to an error-monitoring service. Such services provide valuable insights into the occurrence and impact of errors, enabling developers to identify and resolve issues promptly. By integrating an error-monitoring service into the development workflow, teams can enhance their debugging and troubleshooting capabilities, leading to improved user experience and overall application quality. Examples of some error monitoring services are Honeybadger, Sentry, Datadog, etc.

### Integrating Honeybadger for error monitoring

[Honeybadger](https://www.honeybadger.io/) is a powerful error-monitoring tool for Python applications. Integrating an error monitoring service like Honeybadger into your development workflow provides numerous benefits for effectively managing exceptions. From real-time notifications and error grouping to rich diagnostics and trend analysis, Honeybadger equips you with the tools you need to quickly identify, investigate, and resolve errors and ultimately enhance the overall quality and reliability of your applications. To demo this, let's now explore some features and examples of integrating Honeybadger into your Python code.

To use Honeybadger you need to first get an API key, which you get by signing up to Honeybadger. On the [Honeybadger projects page](https://app.honeybadger.io/projects), click on the “**Create your first project**” button, give your project a name, and finally click the “**Settings**” tab, and you will be able to access your API key there.

You will also need to install Honeybadger locally. You can do that with the following command:

```bash
pip install honeybadger
```

### Customizing Error Notifications

Honeybadger allows you to customize error notifications based on your application's needs. You can specify the severity level, tags, and additional context to be included in the error reports. Here's an example:

```python
from honeybadger import honeybadger

# Configure Honeybadger with your API key
honeybadger.configure(api_key='hbp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX') # paste your API key

def divide(a, b):
    result = a / b
    return result        

# Example usage of the divide function
try:
    result = divide(10, 0)
except Exception as e:
    honeybadger.notify(e, context={'user_id': 123}, error_message='Division by zero is not allowed.') #'Division by zero is not allowed.' will show if `e` was not passed
```

In this example, `honeybadger.notify()` the `context` parameter provides additional contextual information, such as the user ID associated with the error. To manually send an error message to Honeybadger notification, you add the “error_message argument. The message in `error_message` will show up only when there is no error passed in the `honeybadger.notify()` method.

When you run the above code, you will receive in your email a message notifying you of the error, like the message that can be seen below.

![Exception error from honeybadger](/images/blog/posts/a-guide-to-exception-handling-in-python/honeybadger-exc.png)

## Conclusion

Exception handling is a critical aspect of writing robust Python code. By mastering various techniques, from nested try-except blocks to creating custom exceptions, you can enhance the reliability of your code. Additionally, tools such as Honeybadger can aid in error monitoring, allowing you to proactively identify and address issues in your Python applications.

You can check out more applications of Honeybadger in the [documentation](https://docs.honeybadger.io/).