When an exception is raised, the most basic information it provides is its type. However, in nearly all real-world scenarios, this alone is insufficient for effective debugging and error handling. The true power of exceptions is unlocked by attaching contextual information—specific details about the state of the program, the invalid data, or the failed operation at the moment the error occurred. This context transforms a generic error into a precise, actionable diagnostic message.

Using Arguments with Built-in Exceptions

The most straightforward method for adding context is to pass arguments when raising a built-in exception. Most built-in exception classes accept a variable number of arguments, which are stored in the args tuple attribute of the exception object. The string representation of the exception (str(e)) is built from these arguments.

def calculate_square_root(x):
    if x < 0:
        # Pass the invalid value as an argument
        raise ValueError("Input must be non-negative", x)
    return x ** 0.5

try:
    result = calculate_square_root(-9)
except ValueError as e:
    print(f"Error type: {type(e).__name__}")
    print(f"Error message: {e}") # This prints the tuple as a string
    print(f"Arguments tuple: {e.args}")
    # Output:
    # Error type: ValueError
    # Error message: ('Input must be non-negative', -9)
    # Arguments tuple: ('Input must be non-negative', -9)
    
    # You can access individual arguments
    message, invalid_value = e.args
    print(f"Failed with value: {invalid_value}")

This approach is simple and effective for small amounts of data. However, a common pitfall is relying on string manipulation to parse the error message later. Instead, always access the .args tuple or, even better, define specific attributes on a custom exception.

Storing Context in Custom Exception Attributes

For more complex exceptions, especially custom ones, the best practice is to store contextual information in explicitly named attributes. This creates a stable, intuitive interface for anyone handling your exception. They can access data directly by attribute name without unpacking a tuple or parsing a string, which makes the code more readable and robust.

class ConfigurationError(Exception):
    """Exception raised for errors in the application configuration."""
    def __init__(self, message, config_file, section):
        super().__init__(message)
        self.config_file = config_file
        self.section = section
    # Optionally, override __str__ to include the context automatically
    def __str__(self):
        return f"{super().__str__()} (File: {self.config_file}, Section: {self.section})"

def load_config(config_file, section):
    # Simulate an error finding the section
    if section != 'database':
        raise ConfigurationError("Missing required section", config_file, section)

try:
    load_config('app.conf', 'cache')
except ConfigurationError as e:
    print(f"Configuration failed: {e}")
    # Handle the error using the specific attributes
    print(f"Please check section '{e.section}' in file: {e.config_file}")

This method is superior because it is self-documenting. A developer catching a ConfigurationError knows immediately that they can access .config_file and .section. It avoids the ambiguity of a generic tuple and prevents breaking changes if you later decide to add more arguments.

The Importance of Calling super().init()

When creating a custom exception with attributes, it is crucial to call super().__init__(). The base Exception class is responsible for populating the .args tuple. If you override __init__ without calling the parent constructor, the .args tuple will be empty, and the default string representation of your exception will be broken. Always pass the main error message to the parent class.

class IncorrectException(Exception):
    """This implementation is broken."""
    def __init__(self, important_value):
        self.important_value = important_value
        # BUG: Forgot to call super().__init__()

class CorrectException(Exception):
    """This implementation is correct."""
    def __init__(self, message, important_value):
        super().__init__(message)  # Essential for proper behavior
        self.important_value = important_value

# Using the broken version
e_incorrect = IncorrectException(42)
print(e_incorrect.args)  # Output: ()
print(str(e_incorrect))   # Output: <__main__.IncorrectException object at 0x...>

# Using the correct version
e_correct = CorrectException("An error occurred", 42)
print(e_correct.args)     # Output: ('An error occurred',)
print(str(e_correct))     # Output: An error occurred

Best Practices for Contextual Information

  1. Be Specific but Concise: Provide enough context to diagnose the problem without dumping large objects or sensitive data (like passwords) into the exception.
  2. Use Attributes for Stability: Prefer named attributes over positional arguments for anything beyond a simple message. This creates a formal API for your exception.
  3. Consider Overriding str: For custom exceptions, overriding the __str__ method can provide a highly detailed, human-readable error message automatically, incorporating all the relevant attributes.
  4. Preserve Original Exceptions: When translating a low-level exception (e.g., an OSError from a file operation) into a higher-level domain exception, ensure you include the original exception. This is best done with the from keyword (exception chaining) to preserve the full traceback.
try:
    config = open('missing_file.conf').read()
except OSError as original_error:
    raise ConfigurationError("Could not load config file", 'missing_file.conf', '') from original_error
# The traceback will show both the OSError and the ConfigurationError