A guide to exception handling in Python

Discover powerful exception-handling techniques in Python with this comprehensive guide. Learn about nested try-except blocks, catching and re-raising exceptions, handling exceptions in loops and async code, creating custom exceptions, and more.

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:

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:

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:

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.

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:

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.

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:

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.

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:

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:

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:

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 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, 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:

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:

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

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.

What to do next:
  1. Try Honeybadger for FREE
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Start free trial
    Easy 5-minute setup — No credit card required
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo

    Muhammed Ali

    Muhammed is a Software Developer with a passion for technical writing and open source contribution. His areas of expertise are full-stack web development and DevOps.

    More articles by Muhammed Ali
    Stop wasting time manually checking logs for errors!

    Try the only application health monitoring tool that allows you to track application errors, uptime, and cron jobs in one simple platform.

    • Know when critical errors occur, and which customers are affected.
    • Respond instantly when your systems go down.
    • Improve the health of your systems over time.
    • Fix problems before your customers can report them!

    As developers ourselves, we hated wasting time tracking down errors—so we built the system we always wanted.

    Honeybadger tracks everything you need and nothing you don't, creating one simple solution to keep your application running and error free so you can do what you do best—release new code. Try it free and see for yourself.

    Start free trial
    Simple 5-minute setup — No credit card required

    Learn more

    "We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release."
    — Michael Smith, Cofounder & CTO of YvesBlue

    Honeybadger is trusted by top companies like:

    “Everyone is in love with Honeybadger ... the UI is spot on.”
    Molly Struve, Sr. Site Reliability Engineer, Netflix
    Start free trial
    Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
    Start free trial
    Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
    Start free trial
    “Wow — Customers are blown away that I email them so quickly after an error.”
    Chris Patton, Founder of Punchpass.com
    Start free trial