How to Add Context to Error Messages in Python Exceptions
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:
# ⛔️ Incorrect: This hides the fact that a ZeroDivisionError occurred
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:
# ✅ Correct: Wraps the error while keeping the original trace
# Context added: Which user triggered the error
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:
# ✅ Correct: Add context without changing the exception type
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
# Simulating a crash
if sys.version_info >= (3, 11):
try:
main_workflow()
except Exception as e:
# Python's default traceback prints these notes automatically
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
# Format a clear message
message = f"Failed to {action} for User {user_id}"
super().__init__(message)
def delete_user(user_id):
try:
# Simulate an error
raise FileNotFoundError("Database file missing")
except Exception as e:
# ✅ Correct: Raise structural error
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:
# ✅ Correct: Annotate the exception with function arguments
args_str = f"args={args}, kwargs={kwargs}"
if hasattr(e, 'add_note'):
e.add_note(f"Error in '{func.__name__}' with {args_str}")
else:
# Fallback for older Python versions: Modify message args
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 Python 3.11+, notes are printed in traceback automatically
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.
- Use
raise ... from eto chain exceptions and preserve the original traceback. - Use
e.add_note()(Python 3.11+) to simply append text information to an existing error. - Use Custom Classes when you need to catch and handle specific data properties programmatically.
- Use Decorators to apply consistent context logging across multiple functions.