Intermediate
You are debugging a crash in production. The traceback lands on a generic RuntimeError("Failed to load config") — but you have no idea what actually went wrong. Was it a missing file? A JSON parse error? A permissions problem? The original exception is gone, buried somewhere inside the code that caught and re-raised it without preserving the cause. You are left guessing at the root cause from a vague error message, and that is a bad situation to be in at 2am when a service is down.
Python’s exception chaining mechanism exists to solve exactly this problem. When you catch an exception and need to raise a different one, you can use raise NewException(...) from original_exc to link the two together. The traceback then shows the full chain — both the original cause and the new exception — so whoever reads it can trace the problem all the way back to its source. Everything covered here is built into Python’s standard exception system, with no third-party packages required.
This article covers how Python’s implicit and explicit exception chaining works, how to use raise from to set an explicit cause, the difference between __cause__ and __context__, when to use raise from None to suppress the original exception, how to re-raise a caught exception cleanly with bare raise, and how to inspect exception chains programmatically. By the end you will be able to write error-handling code that gives callers accurate, complete stack traces instead of mystery messages.
Exception Chaining in Python: Quick Example
Here is a minimal example showing the two most common patterns — re-raising with a cause, and suppressing the cause — side by side:
# exception_chain_quick.py
def load_config(path):
try:
with open(path) as f:
import json
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as exc:
# Chain: tell the caller WHY loading failed, and preserve the original error
raise RuntimeError(f"Failed to load config from {path}") from exc
def get_user_id(raw_value):
try:
return int(raw_value)
except ValueError as exc:
# Suppress: the ValueError is an internal detail; expose a cleaner error
raise ValueError(f"User ID must be a number, got: {raw_value!r}") from None
# Try chaining
try:
load_config("/nonexistent/config.json")
except RuntimeError as exc:
print(type(exc).__name__, "->", exc)
print("Caused by:", type(exc.__cause__).__name__, exc.__cause__)
# Try suppression
try:
get_user_id("abc")
except ValueError as exc:
print(type(exc).__name__, "->", exc)
print("Caused by:", exc.__cause__) # None -- suppressed
Output:
RuntimeError -> Failed to load config from /nonexistent/config.json
Caused by: FileNotFoundError [Errno 2] No such file or directory: '/nonexistent/config.json'
ValueError -> User ID must be a number, got: 'abc'
Caused by: None
The first case shows chaining: the RuntimeError carries its cause in __cause__, and a real traceback would print both exceptions. The second case shows suppression: the inner ValueError is discarded, and the caller sees only the clean public error. The sections below explain when to use each pattern and how the underlying machinery works.
What Is Exception Chaining and Why Does It Matter?
Every Python exception object can hold a reference to another exception. This lets you capture the full history of what went wrong, not just the final symptom. Without chaining, catching an exception and raising a new one loses all the context from the original — the stack frame, the message, the type. With chaining, a reader of the traceback sees the complete story from root cause to final failure.
Python has two forms of exception chaining. Implicit chaining happens automatically whenever you raise an exception inside an except block. Python sets exc.__context__ to the active exception automatically, so the “During handling of the above exception, another exception occurred” message in a traceback is implicit chaining at work. Explicit chaining happens when you use raise NewExc from original_exc — this sets exc.__cause__ and also sets exc.__suppress_context__ = True, which tells Python to display “The above exception was the direct cause of the following exception” instead of the implicit message.
| Mechanism | How it is set | Attribute | Traceback message |
|---|---|---|---|
| Implicit chaining | Raising inside except block | __context__ | “During handling of the above exception, another exception occurred” |
| Explicit chaining | raise Exc from cause | __cause__ | “The above exception was the direct cause of the following exception” |
| Suppression | raise Exc from None | Both are None | No chained traceback displayed |
The rule of thumb: use explicit chaining (raise from) when the original exception is genuinely useful context for the caller. Use suppression (raise from None) when the original exception is an internal implementation detail that would only confuse the caller. Use bare raise when you want to re-raise the exact same exception without modification.
Using raise from to Set an Explicit Cause
The raise NewException from original syntax explicitly links two exceptions. The new exception is what propagates to the caller; the original is attached as __cause__ and displayed in the traceback above the new exception. Use this pattern when your function catches a low-level exception (like a database error or a network timeout) and raises a higher-level one that makes sense in your abstraction layer.
# raise_from_demo.py
import json
class ConfigError(Exception):
"""Raised when configuration loading or parsing fails."""
def parse_settings(raw_json: str) -> dict:
"""Parse a JSON settings string. Raises ConfigError on failure."""
try:
return json.loads(raw_json)
except json.JSONDecodeError as exc:
raise ConfigError(
f"Settings file contains invalid JSON at line {exc.lineno}, col {exc.colno}"
) from exc
# Simulate bad JSON input
bad_json = '{"timeout": 30, "host": "db.example.com"' # missing closing brace
try:
parse_settings(bad_json)
except ConfigError as exc:
print(f"ConfigError: {exc}")
print(f"__cause__ type: {type(exc.__cause__).__name__}")
print(f"__cause__ msg: {exc.__cause__}")
print(f"__suppress_context__: {exc.__suppress_context__}")
Output:
ConfigError: Settings file contains invalid JSON at line 1, col 41
__cause__ type: JSONDecodeError
__cause__ msg: Expecting property name enclosed in double quotes: line 1 column 41 (char 40)
__suppress_context__: True
The caller catches a clean ConfigError with a human-readable message, but the original JSONDecodeError is preserved in __cause__. In a real traceback (not caught by a try/except), Python would print the JSONDecodeError first with “The above exception was the direct cause of the following exception”, then print the ConfigError. Notice that __suppress_context__ is automatically set to True by raise from — this tells Python to use the “direct cause” wording instead of the implicit “during handling” wording.
Understanding __cause__ vs __context__
Python maintains both __cause__ and __context__ on every exception, and they serve different purposes. __context__ is set automatically whenever an exception is raised while another exception is already being handled — it is the exception that was active at the time. __cause__ is only set when you use raise ... from ..., and it signals an intentional, semantic relationship: “this exception was directly caused by that one.”
# cause_vs_context.py
def implicit_context():
"""Raises inside an except block -- sets __context__ automatically."""
try:
int("not-a-number") # raises ValueError
except ValueError:
raise RuntimeError("Something went wrong internally")
def explicit_cause():
"""Uses raise from -- sets __cause__ explicitly."""
try:
int("not-a-number") # raises ValueError
except ValueError as exc:
raise RuntimeError("Input conversion failed") from exc
def mixed():
"""Both are set when you use raise from inside an except block."""
try:
int("not-a-number")
except ValueError as exc:
new_exc = RuntimeError("Conversion error")
raise new_exc from exc
# new_exc.__cause__ == the ValueError (explicit)
# new_exc.__context__ == the ValueError (implicit, same exception here)
# Inspect implicit chaining
try:
implicit_context()
except RuntimeError as exc:
print("=== Implicit context ===")
print(f"__cause__: {exc.__cause__}")
print(f"__context__ type: {type(exc.__context__).__name__}")
print(f"__suppress_context__: {exc.__suppress_context__}")
# Inspect explicit cause
try:
explicit_cause()
except RuntimeError as exc:
print("\n=== Explicit cause ===")
print(f"__cause__ type: {type(exc.__cause__).__name__}")
print(f"__context__ type: {type(exc.__context__).__name__}")
print(f"__suppress_context__: {exc.__suppress_context__}")
Output:
=== Implicit context ===
__cause__: None
__context__ type: ValueError
__suppress_context__: False
=== Explicit cause ===
__cause__ type: ValueError
__context__ type: ValueError
__suppress_context__: True
In the implicit case, __context__ is set (because we raised inside an except block) but __cause__ is None and __suppress_context__ is False. Python will show the “During handling” message. In the explicit case, both __cause__ and __context__ point to the same ValueError, but __suppress_context__ is True — so Python uses the “direct cause” wording and ignores __context__ for display purposes. The key rule: raise from always sets __suppress_context__ = True, which changes both the traceback wording and which exception Python chooses to display.
Suppressing the Original Exception with raise from None
Sometimes you catch a low-level exception but the implementation detail it represents should not leak to the caller. A classic example: a KeyError inside a dictionary lookup that you expose as a public LookupError. If you let the KeyError surface in the traceback, callers start depending on your internal data structure — they write except KeyError instead of except LookupError, and now you cannot change the implementation. Using raise NewExc from None suppresses both __cause__ and __context__, producing a clean traceback that shows only the new exception.
# raise_from_none.py
class UserNotFoundError(Exception):
"""Public exception for the user lookup API."""
# Internal implementation uses a dict -- an implementation detail
_USER_STORE = {
"alice": {"id": 1, "role": "admin"},
"bob": {"id": 2, "role": "viewer"},
}
def get_user(username: str) -> dict:
"""Look up a user by name. Raises UserNotFoundError if not found."""
try:
return _USER_STORE[username] # raises KeyError internally
except KeyError:
# Suppress: callers should not see or depend on KeyError
raise UserNotFoundError(f"No user named {username!r}") from None
# Caller never sees the KeyError
try:
get_user("mallory")
except UserNotFoundError as exc:
print(f"Caught: {exc}")
print(f"__cause__: {exc.__cause__}")
print(f"__context__: {exc.__context__}")
print(f"__suppress_context__: {exc.__suppress_context__}")
Output:
Caught: No user named 'mallory'
__cause__: None
__context__: None
__suppress_context__: True
Both __cause__ and __context__ are None. This is the special case of raise from None: it sets __cause__ = None, __context__ = None, and __suppress_context__ = True, so the traceback shows only the new exception with no chaining information. Use this sparingly — only when you are certain the original exception would actively mislead callers. In most cases, preserving the cause with raise from exc is more helpful for debugging.
Re-raising Exceptions with bare raise
Sometimes you want to inspect a caught exception, take some action (log it, update a counter, release a resource), and then let it propagate unchanged. Bare raise — with no argument — re-raises the current exception exactly as it was, including all its attributes, original traceback, and any chaining information. It does not create a new exception or modify the existing one.
# bare_raise.py
import logging
logging.basicConfig(level=logging.ERROR, format="%(levelname)s: %(message)s")
_error_count = 0
def risky_parse(value: str) -> int:
"""Parse an integer, log any failure, then re-raise."""
global _error_count
try:
return int(value)
except ValueError:
_error_count += 1
logging.error("Parse failed for value %r (total errors: %d)", value, _error_count)
raise # re-raise the original ValueError unchanged
# First call -- fails
try:
risky_parse("bad")
except ValueError as exc:
print(f"Caller caught: {type(exc).__name__}: {exc}")
print(f"Error count so far: {_error_count}")
# Second call -- also fails
try:
risky_parse("also bad")
except ValueError as exc:
print(f"Caller caught: {type(exc).__name__}: {exc}")
print(f"Error count so far: {_error_count}")
# Third call -- succeeds
result = risky_parse("42")
print(f"Parsed successfully: {result}")
Output:
ERROR: Parse failed for value 'bad' (total errors: 1)
Caller caught: ValueError: invalid literal for int() with base 10: 'bad'
Error count so far: 1
ERROR: Parse failed for value 'also bad' (total errors: 2)
Caller caught: ValueError: invalid literal for int() with base 10: 'also bad'
Error count so far: 2
Parsed successfully: 42
The ValueError the caller receives is the original exception object — not a copy, not a new instance. Its traceback points back to the int(value) call inside risky_parse, not to the bare raise statement. This is the critical difference between bare raise and raise exc (re-raising by variable): raise exc resets the traceback start to the line containing raise exc, hiding where the exception actually originated. Always prefer bare raise when re-raising the same exception in an except block.
Inspecting Exception Chains Programmatically
For logging frameworks, error-reporting systems, or debugging utilities, you sometimes need to walk the full exception chain rather than just letting Python print it. You can traverse __cause__ and __context__ yourself to collect every exception in the chain, from the outermost to the innermost root cause.
# inspect_chain.py
def collect_chain(exc):
"""Walk the exception chain and return a list of all exceptions, outermost first."""
chain = []
visited = set()
current = exc
while current is not None and id(current) not in visited:
chain.append(current)
visited.add(id(current))
# Prefer __cause__ (explicit) over __context__ (implicit)
if current.__cause__ is not None:
current = current.__cause__
elif not current.__suppress_context__ and current.__context__ is not None:
current = current.__context__
else:
break
return chain
def format_chain(exc):
"""Return a structured summary of the full exception chain."""
chain = collect_chain(exc)
parts = []
for i, e in enumerate(chain):
relationship = ""
if i == 0:
relationship = "[outermost]"
elif chain[i-1].__cause__ is e:
relationship = "[direct cause]"
else:
relationship = "[context]"
parts.append(f" {relationship} {type(e).__name__}: {e}")
return "\n".join(parts)
# Build a three-level chain
try:
try:
try:
int("xyz") # root: ValueError
except ValueError as e1:
raise OSError("IO layer failed") from e1 # middle: OSError
except OSError as e2:
raise RuntimeError("Top-level failure") from e2 # outer: RuntimeError
except RuntimeError as final:
print("Exception chain (outermost -> root cause):")
print(format_chain(final))
print()
chain = collect_chain(final)
print(f"Root cause: {type(chain[-1]).__name__}: {chain[-1]}")
Output:
Exception chain (outermost -> root cause):
[outermost] RuntimeError: Top-level failure
[direct cause] OSError: IO layer failed
[direct cause] ValueError: invalid literal for int() with base 10: 'xyz'
Root cause: ValueError: invalid literal for int() with base 10: 'xyz'
The traversal prefers __cause__ over __context__ because explicit chaining is the intentional relationship. It also guards against cycles using a visited set — a defensive measure since circular exception chains are theoretically possible (though rare in practice). The format_chain function is a lightweight building block you can drop into any logging or error-reporting utility to include the full chain in your structured logs.
Real-Life Example: Resilient Database Query Handler
Here is a practical module that wraps a database layer (simulated with a dictionary) and demonstrates all four patterns — explicit chaining, suppression, bare re-raise, and chain inspection — in a realistic context. The module translates low-level database exceptions into domain-specific ones while preserving enough context for effective debugging.
# db_handler.py
import logging
logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("db_handler")
# --- Exceptions ---
class DatabaseError(Exception):
"""Base class for all database errors in this module."""
class RecordNotFoundError(DatabaseError):
"""Raised when a queried record does not exist."""
class DataCorruptionError(DatabaseError):
"""Raised when stored data cannot be deserialized."""
# --- Simulated low-level storage ---
import json
_STORE = {
"user:1": '{"name": "Alice", "role": "admin"}',
"user:2": '{"name": "Bob", "role": "viewer"}',
"user:3": '{CORRUPTED DATA}', # simulates corruption
}
def _raw_fetch(key: str) -> str:
"""Low-level fetch -- raises KeyError if the key does not exist."""
return _STORE[key] # KeyError is an implementation detail
# --- Public API ---
def get_user(user_id: int) -> dict:
"""
Fetch a user record by ID.
Raises:
RecordNotFoundError: if the user does not exist.
DataCorruptionError: if the stored data cannot be parsed.
DatabaseError: for unexpected low-level failures.
"""
key = f"user:{user_id}"
# Fetch the raw record
try:
raw = _raw_fetch(key)
except KeyError:
# Suppress: KeyError is internal; the public contract is RecordNotFoundError
raise RecordNotFoundError(f"User {user_id} not found") from None
# Parse the record
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
# Chain: the JSON error is directly responsible and useful for debugging
raise DataCorruptionError(
f"Stored data for user {user_id} is not valid JSON"
) from exc
# Validate expected shape
if not isinstance(data, dict) or "name" not in data:
raise DataCorruptionError(
f"User {user_id} record is missing required fields"
) from None
return data
def safe_get_user(user_id: int) -> dict | None:
"""
Like get_user, but returns None for missing records instead of raising.
Re-raises DataCorruptionError -- corruption is always worth knowing about.
"""
try:
return get_user(user_id)
except RecordNotFoundError:
return None
except DataCorruptionError:
logger.warning("Corruption detected for user %d -- propagating", user_id)
raise # bare raise: preserve the original exception and its chain
# --- Demo ---
print("=== Normal fetch ===")
user = get_user(1)
print(f"Found: {user}")
print("\n=== Missing user (suppressed) ===")
try:
get_user(99)
except RecordNotFoundError as exc:
print(f"RecordNotFoundError: {exc}")
print(f" __cause__: {exc.__cause__}")
print("\n=== Corrupted record (chained) ===")
try:
get_user(3)
except DataCorruptionError as exc:
print(f"DataCorruptionError: {exc}")
print(f" __cause__ type: {type(exc.__cause__).__name__}")
print(f" __cause__ msg: {exc.__cause__}")
print("\n=== safe_get_user: missing ===")
result = safe_get_user(99)
print(f"Result: {result}")
print("\n=== safe_get_user: corrupted -- re-raised ===")
try:
safe_get_user(3)
except DataCorruptionError as exc:
print(f"Re-raised: {type(exc).__name__}: {exc}")
# __cause__ is still intact from the original raise in get_user
print(f" __cause__ still attached: {type(exc.__cause__).__name__}")
Output:
=== Normal fetch ===
Found: {'name': 'Alice', 'role': 'admin'}
=== Missing user (suppressed) ===
RecordNotFoundError: User 99 not found
__cause__: None
=== Corrupted record (chained) ===
DataCorruptionError: Stored data for user 3 is not valid JSON
__cause__ type: JSONDecodeError
__cause__ msg: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
=== safe_get_user: missing ===
Result: None
=== safe_get_user: corrupted -- re-raised ===
WARNING db_handler: Corruption detected for user 3 -- propagating
Re-raised: DataCorruptionError: Stored data for user 3 is not valid JSON
__cause__ still attached: JSONDecodeError
The module demonstrates all four patterns in a coherent API. get_user uses suppression for the missing-record case (callers should not see KeyError) and explicit chaining for the corruption case (the JSONDecodeError details are valuable). safe_get_user uses bare raise to let corruption propagate without altering the exception — the __cause__ chain from the original raise in get_user is still fully intact when the caller catches it. Extend this pattern by adding retry logic around _raw_fetch, structured JSON logging of the chain using collect_chain, or a context manager that wraps arbitrary code and translates all Exception subclasses into domain errors.
Frequently Asked Questions
What is the difference between bare raise and raise exc?
Bare raise (with no argument) re-raises the current exception exactly as it was, preserving its original traceback. The traceback will point to the original site where the exception was first raised, not to the bare raise statement. raise exc (raising the caught exception by name) creates a new raise site — the traceback will now start at the line with raise exc, hiding the original origin. In an except block, always prefer bare raise unless you intentionally want to change the traceback origin. The only reason to use raise exc is if you are storing the exception and raising it later, outside of the except block where it was caught.
When should I use raise from None instead of raise from exc?
Use raise from None when the original exception is an internal implementation detail that would actively mislead callers. The classic cases are: catching a KeyError from a private dictionary and raising a public LookupError, catching a StopIteration inside a generator and re-raising a different error, or translating third-party library exceptions into your own domain exceptions when the library’s exception type would expose your dependency choices. If you are unsure, default to raise from exc — preserving context is almost always more helpful for debugging than hiding it.
How do I read a chained exception traceback?
Python prints exception chains from the oldest (innermost) exception at the top to the newest (outermost) at the bottom. Read them bottom-up to understand what failed from a user perspective, and top-down to understand the root cause. The separator line tells you the relationship: “The above exception was the direct cause of the following exception” means explicit chaining (raise from); “During handling of the above exception, another exception occurred” means implicit chaining (raised in an except block without from). The outermost exception (at the very bottom) is the one your except clause catches.
Can exception chaining happen in a finally block?
Yes, and it can be surprising. If an exception is raised inside a finally block while another exception is already propagating, Python sets __context__ on the new exception, creating an implicit chain. The new exception from the finally block replaces the original as the propagating exception, but the original is still accessible via __context__. This means a noisy finally block (one that can itself raise) can swallow or obscure the original error. The fix is to wrap the body of your finally block in its own try/except and handle errors there, so cleanup failures do not mask the original exception.
Do I need to do anything special for custom exception classes?
No — all exception chaining behavior is inherited from BaseException, so any class that inherits from Exception (directly or indirectly) already has __cause__, __context__, and __suppress_context__ as built-in attributes. You do not need to add anything to your custom exception class to use raise from with it. If you want to add extra context fields (like a request ID or a user ID) that travel with the exception, define them as constructor arguments and store them as instance attributes — they will be accessible alongside the chaining attributes.
Does exception chaining work the same way in async code?
Yes, exception chaining works identically in async code. raise from, bare raise, and __cause__/__context__ all behave the same inside async def functions, async with blocks, and async for loops. The only complication is with asyncio.TaskGroup or asyncio.gather: when multiple tasks fail simultaneously, Python wraps them in an ExceptionGroup (Python 3.11+), which is a separate mechanism for collecting multiple simultaneous exceptions. The except* syntax handles those. For single-task async error handling, use the same patterns covered in this article.
Conclusion
In this article we covered how Python’s two exception chaining mechanisms work — implicit chaining via __context__ and explicit chaining via raise from and __cause__. We saw how to use raise NewExc from original to preserve context across abstraction boundaries, how raise from None suppresses the original exception when it would only mislead callers, and how bare raise re-raises the current exception without altering its traceback origin. The collect_chain utility showed how to walk the chain programmatically for logging and reporting, and the database handler example tied all four patterns together in a realistic API.
A good next step is to add collect_chain to your application’s logging configuration so that every logged exception automatically includes its full chain, not just the outermost message. For structured logging (JSON logs), serialize each exception in the chain as a separate entry in a list field — that way log analysis tools can filter by root cause type without parsing traceback strings. The official Python documentation on exception chaining covers the language spec in full detail, and PEP 3134 is the original proposal that introduced the mechanism — it explains the design rationale clearly and is worth reading if you want to understand why the two-chain model exists.