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: ZeroDivisionErrorUsing 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=0Custom 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: deleteContext 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.
- 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.