Exception chaining, introduced formally in Python 3.0 and enhanced in Python 3.3 with the __suppress_context__ attribute, is a mechanism that explicitly preserves the original exception (Y) when a new exception (X) is raised in response to it. This creates a causal chain of exceptions, which is invaluable for debugging as it provides a complete traceback of the error’s origin and propagation, rather than just the point where it finally became unhandled.

The primary syntax for this feature is the raise ... from ... statement: raise NewException("message") from original_exception. The from keyword creates a direct link between the two exception objects. This link is stored in the __cause__ attribute of the new exception. When the interpreter prints the traceback, it clearly displays both exceptions, explicitly stating “The above exception was the direct cause of the following exception:”. This is fundamentally different from implicit chaining, which occurs when an exception is raised inside an except block without using from. In that case, the original exception is stored in the __context__ attribute and the traceback message reads “During handling of the above exception, another exception occurred:”, indicating a secondary error rather than a direct translation.

The Difference Between __cause__ and __context__

Understanding the distinction between these two attributes is crucial. The __cause__ is set explicitly by the raise ... from ... syntax and denotes a direct, intentional causation. The __context__ is set automatically by the Python interpreter when an exception is raised in an except or finally block, and it simply shows the exception that was being handled at the time. The __suppress_context__ attribute, when set to True on an exception, tells the interpreter not to display the __context__ automatically. This is useful when you are raising a new exception with from to show a direct cause and you want to avoid cluttering the output with the intermediary context.

def read_config_file(filename):
    try:
        with open(filename, 'r') as f:
            return f.read()
    except FileNotFoundError as e:
        # Implicit chaining: __context__ is set
        raise RuntimeError(f"Config file '{filename}' is missing.")

def parse_config(config):
    try:
        # ... parsing logic that might fail ...
        yaml.safe_load(config)
    except yaml.YAMLError as e:
        # Explicit chaining: __cause__ is set, __suppress_context__ is True
        raise ValueError("Invalid YAML syntax in config.") from e

# Scenario 1: Implicit chaining via __context__
try:
    read_config_file('missing_file.conf')
except RuntimeError as e:
    print(f"Caught: {e}")
    print(f"Context: {e.__context__}")  # The original FileNotFoundError

# Scenario 2: Explicit chaining via __cause__
try:
    config = "# Invalid YAML: missing closing quote\n'open_quote"
    parse_config(config)
except ValueError as e:
    print(f"Caught: {e}")
    print(f"Cause: {e.__cause__}")  # The original YAMLError

Best Practices and Common Pitfalls

A key best practice is to use explicit chaining (from) when your new exception is a direct and meaningful re-interpretation of a lower-level exception. This is common when implementing APIs or libraries where you want to expose errors specific to your domain while obscuring the underlying implementation details. For instance, a database library might catch a network socket error and re-raise it as a custom ConnectionFailedError using from, providing a more semantically appropriate error for its users without losing the root cause.

A common pitfall is to use raise ... from None. This sets the __cause__ to None and, more importantly, sets __suppress_context__ to True, completely disabling the display of any previous exceptions that were in context. This should be used sparingly, only when you are absolutely certain the original exception provides no useful debugging information and would merely serve as a distraction.

def get_user_id(username):
    if not username.isalpha():
        # This is a user error; the internal ValueError traceback is irrelevant.
        raise ValueError("Username must be letters only.") from None
    # ...

Another pitfall is unnecessarily chaining exceptions when the original exception is already perfectly suitable for the caller. If a FileNotFoundError occurs and that is exactly what your function’s caller would expect and know how to handle, simply re-raise it with a bare raise statement or let it propagate. Only introduce a new exception type if it adds meaningful, higher-level information.

When creating custom exceptions, ensure they are designed to be chained. The standard library exceptions already handle the __cause__, __context__, and __suppress_context__ attributes correctly. If you need to replicate this behavior manually, you would pass the cause parameter during instantiation or set the attributes after creation, though using the raise NewExc from cause idiom is always preferable.