Back to Blog
Python

Python Exception Chaining: How to Preserve the Root Cause

python exception chaining: Learn how Python exception chaining preserves the original error when a new exception is raised, using `raise ... from` and `__cause__` to k...

exception chainingraise fromtracebackerror handlingPython exceptions
Diagram showing an exception chained to its cause in a Python traceback.

When an exception occurs while another exception is being handled, Python chains the two together. This behavior, known as python exception chaining, is visible in the traceback and is controlled by attributes like __context__ and __cause__. Understanding how to use raise ... from lets you preserve the original error while raising a more domain-specific one.

How Implicit Chaining Works

If you raise a new exception inside an except block without any explicit chaining syntax, Python automatically attaches the original exception to the new one. The original exception is stored in the __context__ attribute of the new exception, and the traceback shows both exceptions.

def read_config(path): try: with open(path) as f: return f.read() except FileNotFoundError as e: raise ValueError(f"Missing config file: {path}")

When read_config is called with a nonexistent path, the traceback will show the FileNotFoundError as the direct cause and the ValueError as the exception that propagated. This implicit chaining is useful because it preserves the low-level failure even when you translate it into a higher-level error.

However, implicit chaining can be misleading if the original exception is not the true cause. For example, if you catch an exception and then raise a different error that is unrelated, the traceback still shows the original exception, which might confuse debugging.

Explicit Chaining with raise ... from

To make the relationship between the original exception and the new one explicit, use the from keyword. This sets the __cause__ attribute on the new exception and makes the traceback clearer about the intent.

def load_user(user_id): try: return fetch_user_from_db(user_id) except DatabaseError as e: raise UserNotFoundError(user_id) from e

Here, UserNotFoundError is raised with from e, so e becomes the __cause__. In the traceback, the message will include "The above exception was the direct cause of the following exception:" which signals that the database error caused the user error. This is more accurate than relying on implicit context when the original exception is the actual reason for the failure.

Use explicit chaining whenever you are intentionally translating one exception into another. It documents the relationship in the code and in the traceback, making the flow of errors easier to follow.

Suppressing the Context with raise ... from None

Sometimes the original exception is implementation detail that should not be exposed to the caller. For example, when you catch a low-level I/O error and raise a business-level exception, you might not want the traceback to include the internal error. In that case, use raise ... from None to suppress the context.

def parse_config(data): try: return json.loads(data) except json.JSONDecodeError as e: raise InvalidConfigError("Config is not valid JSON") from None

With from None, the __context__ and __cause__ attributes are set to None, and the traceback shows only the InvalidConfigError. This is appropriate when the original exception contains sensitive information, or when the caller has no use for the internal details. Be careful not to overuse it, because hiding the root cause can make debugging harder when the new exception does not fully explain the problem.

Accessing __cause__ and __context__ in Exception Handlers

When you catch an exception, you can inspect its __cause__ and __context__ attributes to retrieve the original exception programmatically. This is useful for logging, retry logic, or building custom error responses.

try: process_order(order) except OrderError as e: original = e.__cause__ or e.__context__ if original: logger.error("Order failed due to %s", original) else: logger.error("Order failed: %s", e)

The __cause__ is set only when you use explicit from. The __context__ is set automatically for implicit chaining. Checking both attributes lets you handle either style consistently. Note that __suppress_context__ is a boolean that controls whether the context is shown in the traceback; it is True when you use from None.

Chaining in Custom Exception Classes

If you define your own exception classes, they inherit the default behavior. You can also design them to accept a cause explicitly, which can make the code more readable than relying on from at the raise site.

class PaymentError(Exception): def __init__(self, message, cause=None): super().__init__(message) self.cause = cause def charge_card(card): try: gateway.charge(card) except GatewayTimeout as e: raise PaymentError("Payment gateway timed out", cause=e)

In this pattern, the cause is stored as an attribute, but it is not automatically linked in the traceback. To get the full chaining behavior, you still need to use raise PaymentError(...) from e at the raise site. Storing the cause as an attribute is useful when you want to pass it to a different logging system or include it in a serialized error response.

Debugging and Logging Implications

Exception chaining directly affects how errors appear in logs and debuggers. When an exception is chained, the traceback includes multiple frames, which can be verbose but also informative. Logging frameworks that capture tracebacks will include the entire chain, so you can see the original failure even if the top-level exception is generic.

For example, a log entry for a ValueError that was raised from a FileNotFoundError will show both exceptions. This is often more useful than a single exception because it explains why the operation failed. However, if you use from None too liberally, you lose that information, and the log may not contain enough detail to diagnose the problem.

The performance overhead of chaining is negligible. Creating a new exception and setting attributes is cheap compared to I/O or network operations. You should not avoid chaining for performance reasons; instead, focus on using it intentionally to preserve the most relevant diagnostic information.

Common Mistakes and How to Avoid Them

One common mistake is forgetting to use from when you intend to translate an exception. The implicit context might be misleading if the original exception is not the direct cause. Always ask yourself whether the original exception is the reason for the new one. If it is, use from; if not, consider from None.

Another mistake is using from None when you actually want to preserve the cause. This often happens when developers want to hide internal details but later find that the new exception alone is insufficient for debugging. A better approach is to include the original exception message in the new exception's message, or to log the original exception separately before raising the new one.

Finally, be careful when catching an exception and then raising a new one outside the except block. If you store the exception in a variable and raise it later, the chaining behavior may not be what you expect. For example:

try: risky_operation() except ValueError as e: saved = e raise RuntimeError("Failed") from saved

This works, but it separates the raise from the handling, which can make the code harder to follow. It is usually clearer to raise the new exception inside the except block so the chaining is immediately visible.

When Chaining Can Be Confusing

Even with proper chaining, a long chain of exceptions can become difficult to read. If you have multiple layers of translation, each adding its own context, the traceback can grow large. In such cases, consider whether each layer needs to preserve the previous exception. Sometimes it is better to wrap the original exception only once and let higher layers add context through messages rather than additional chaining.

For example, a database layer might raise a DatabaseError, which is then caught by a service layer and re-raised as a ServiceError, and finally caught by an API layer and re-raised as an ApiError. The traceback will show all three. This is useful for tracing the full path, but it can be noisy. If the API layer only needs to know that the service failed, it might be sufficient to raise ApiError with a message that includes the service error, without chaining the entire stack.

A practical approach is to use chaining when the original exception is the direct cause, and to use from None or a custom message when the relationship is not causal. This keeps tracebacks informative without being overwhelming. Ultimately, the goal is to make the error behavior clear to the developer reading the code and the operator reading the logs.

python exception chaining: Practical Usage and Code Examples | RYUSLOG DEV