Skip to content

How to Add Context to Error Messages in Python Exceptions

Learn advanced Python techniques for enriching exceptions with context (using exception chaining, add_note(), custom exception classes, and decorators) to create actionable debugging information.

In Python programming, effective error handling goes beyond just catching exceptions. A generic "Something went wrong" message is rarely helpful when debugging production code. To build robust and maintainable software, you need to enrich exceptions with specific context (such as variable values, function states, or user IDs) that helps diagnose the root cause immediately.

This guide explores advanced techniques for adding context to exception messages, including exception chaining, custom classes, and decorators.

Exception Chaining with from

When you catch an exception and raise a new one (e.g., catching a low-level KeyError and raising a high-level DatabaseError), you often lose the original traceback. Python provides the from keyword to link the new exception to the original one, preserving the history.

Problem: Losing Context

def process_data(data):
    try:
        return data['value'] / 0
    except ZeroDivisionError:
        raise ValueError("Calculation failed")

Solution: Explicit Chaining

Use raise NewException(...) from OldException to attach the original error as the __cause__.

def process_data_context(user_id, data):
    try:
        return data['value'] / 0
    except ZeroDivisionError as e:
        raise RuntimeError(f"Calculation failed for User ID: {user_id}") from e

try:
    process_data_context(42, {'value': 100})
except RuntimeError as err:
    print(f"Caught: {err}")
    print(f"Original cause: {type(err.__cause__).__name__}")

Output:

Caught: Calculation failed for User ID: 42
Original cause: ZeroDivisionError

Using add_note() (Python 3.11+)

Introduced in Python 3.11, the add_note() method allows you to attach arbitrary string notes to an existing exception object without raising a new one. This is excellent for enriching errors as they bubble up the stack.

import sys

def complex_calculation(x, y):
    if y == 0:
        raise ValueError("Cannot divide by zero")
    return x / y

def main_workflow():
    try:
        complex_calculation(10, 0)
    except Exception as e:
        e.add_note(f"Context: Failed during main workflow execution.")
        e.add_note(f"Input values were x=10, y=0")
        raise  # Re-raise the enriched exception

if sys.version_info >= (3, 11):
    try:
        main_workflow()
    except Exception as e:
        print(f"Exception: {e}")
        for note in e.__notes__:
            print(f"Note: {note}")

Output:

Exception: Cannot divide by zero
Note: Context: Failed during main workflow execution.
Note: Input values were x=10, y=0

Custom Exception Classes

For domain-specific errors, creating a custom class allows you to store structured data (like error codes or user objects) alongside the message.

class UserProcessingError(Exception):
    """Custom exception that stores user state."""
    def __init__(self, user_id, action, original_error=None):
        self.user_id = user_id
        self.action = action
        self.original_error = original_error
        
        message = f"Failed to {action} for User {user_id}"
        super().__init__(message)

def delete_user(user_id):
    try:
        raise FileNotFoundError("Database file missing")
    except Exception as e:
        raise UserProcessingError(user_id, "delete", e) from e

try:
    delete_user(99)
except UserProcessingError as e:
    print(f"Error: {e}")
    print(f"Metadata -> ID: {e.user_id}, Action: {e.action}")

Output:

Error: Failed to delete for User 99
Metadata -> ID: 99, Action: delete

Context Decorators

If you have many functions that need similar error handling (e.g., logging arguments when a crash occurs), a decorator is the cleanest solution. It wraps any function and automatically adds context if an exception escapes.

import functools

def add_debug_context(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except Exception as e:
            args_str = f"args={args}, kwargs={kwargs}"
            
            if hasattr(e, 'add_note'):
                e.add_note(f"Error in '{func.__name__}' with {args_str}")
            else:
                e.args = (f"{e.args[0]} (Context: {func.__name__}, {args_str})",) + e.args[1:]
            raise
    return wrapper

@add_debug_context
def connect_database(host, port):
    raise ConnectionError("Connection timed out")

try:
    connect_database("localhost", 5432)
except ConnectionError as e:
    if hasattr(e, '__notes__'):
        print(e.__notes__[0])
    else:
        print(e)

Output:

Error in 'connect_database' with args=('localhost', 5432), kwargs={}

Conclusion

Adding context to exceptions turns cryptic error logs into actionable debugging reports.

  1. Use raise ... from e to chain exceptions and preserve the original traceback.
  2. Use e.add_note() (Python 3.11+) to simply append text information to an existing error.
  3. Use Custom Classes when you need to catch and handle specific data properties programmatically.
  4. Use Decorators to apply consistent context logging across multiple functions.
Navigation