Python prints two different sentences between chained exceptions, and they mean different things. The above exception was the direct cause of the following exception: says someone deliberately converted one error into another with raise NewError(...) from err. During handling of the above exception, another exception occurred: says an exception escaped from an except or finally block while the first one was still being handled — which is often not deliberate at all, and frequently means the error handler has its own bug.
Reading the two correctly changes where you look. A direct-cause chain usually has its root cause at the top, with the wrapper at the bottom describing it at a higher level. A during-handling chain often has two problems: the original failure at the top, and a second failure in the code that was supposed to deal with it at the bottom. Fixing only the bottom one leaves the original error unhandled; fixing only the top one leaves a fragile handler that will break again next time.
Prerequisites
- Python 3.11 or later (for fine-grained error locations and exception notes).
- Familiarity with Reading tracebacks and exception chains.
Solution
# The three chaining forms side by side.
import json
def load_config_implicit(path):
try:
return json.load(open(path))
except json.JSONDecodeError:
log.error("bad config at %s", pth) # typo → NameError inside handler
def load_config_explicit(path):
try:
return json.load(open(path))
except json.JSONDecodeError as err:
raise ConfigError(f"invalid JSON in {path}") from err
def lookup_plan(plans, name):
try:
return plans[name]
except KeyError:
raise UnknownPlan(name) from None # KeyError is noise for the caller
Traceback (most recent call last):
File "config.py", line 5, in load_config_implicit
return json.load(open(path))
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "config.py", line 7, in load_config_implicit
log.error("bad config at %s", pth)
^^^
NameError: name 'pth' is not defined. Did you mean: 'path'?
Why this works
Every exception object carries three attributes that describe its relationship to others. __context__ is set automatically whenever an exception is raised while another is being handled — inside an except block, or inside a finally block during unwinding. __cause__ is set only by raise X from Y. __suppress_context__ is set to true by any raise ... from ..., including from None, and tells the traceback printer to skip the implicit context.
The traceback printer walks the chain from the propagated exception backwards and prints oldest first. If __cause__ is set, it prints the cause with the direct-cause separator. Otherwise, if __context__ is set and not suppressed, it prints the context with the during-handling separator. That is why the order on screen runs from the first failure at the top to the propagated one at the bottom: the bottom exception is the one your except clauses — or pytest — actually saw.
In the example, the program failed with NameError, not JSONDecodeError. A caller with except JSONDecodeError would not catch it. That is the practical danger of implicit chains: a bug in a handler changes the type of the error that escapes, and code written to handle the original type silently stops working.
Reading chains inside pytest
pytest prints chains the same way, with its own formatting. With the default --tb=auto, each exception in the chain gets its own section, separated by the same sentences, and the final section carries the E lines of the propagated exception. Two things are worth knowing.
First, pytest.raises matches the propagated exception only. with pytest.raises(JSONDecodeError) fails on the implicit chain above, because what escaped was a NameError. If a test that used to pass starts failing that way, read the chain: the handler probably broke.
Second, --tb=short and --tb=line shorten or drop the chain entirely. When a failure is confusing, re-run with --tb=long to see every frame of every exception, or use --full-trace to include pytest's own frames as well.
Writing handlers that chain on purpose
Most implicit chains are accidents, and the fix is to make every handler's intent explicit. Three rules cover nearly every case.
Translate with from err. When a lower layer's exception should become a higher layer's error — a JSONDecodeError becoming a ConfigError, a database IntegrityError becoming a DuplicateUser — always write raise NewError(...) from err. The traceback then says "direct cause", which tells the next reader the translation was deliberate, and err.__cause__ gives programmatic access to the original for logging or retry decisions.
Hide with from None, sparingly. When the lower-level exception is an implementation detail that adds nothing — an internal KeyError from a dict lookup that the caller only knows as "unknown plan" — from None keeps the caller's traceback short. Do this only where the original genuinely has no diagnostic value; hiding an OSError with an errno is a false economy.
Keep handlers boring. The during-handling message usually appears because a handler did something that could fail: formatted a message with a misspelled variable, called a cleanup function that raised, logged an object whose __repr__ raised. Handlers should do as little as possible, and anything risky inside them — closing a connection, sending a metric — should be wrapped in its own try so a failure there cannot replace the original error.
Python 3.11's add_note offers a fourth option that avoids chaining altogether. Instead of wrapping an exception to add context, add a note and re-raise the original: err.add_note(f"while loading {path}") then raise. The traceback shows the original exception with the note underneath, the type is unchanged, and callers' except clauses keep working.
Testing the chain itself
When a function's contract says it raises ConfigError for invalid files, the chain is part of that contract too: callers may log err.__cause__ or branch on it. A small test pins the behaviour so a future refactor cannot quietly turn a deliberate translation into an accidental one.
def test_invalid_json_is_translated(tmp_path):
bad = tmp_path / "c.json"
bad.write_text("{not json")
with pytest.raises(ConfigError) as info:
load_config_explicit(bad)
assert isinstance(info.value.__cause__, json.JSONDecodeError)
assert "c.json" in str(info.value)
The first assertion checks the type callers rely on. The second checks that the cause is the original parsing error rather than something a buggy handler raised. The third checks the message is useful. A test like this would have caught the misspelled variable in the implicit example immediately: pytest.raises(ConfigError) fails when what escapes is a NameError, and the chain printed in the failure shows exactly why. Tests of this shape are cheap to write for every public function that translates errors, and they turn the chain from an accident of implementation into a documented, enforced part of the interface that reviewers can see.
Edge cases and failure modes
finallyblocks. An exception infinallyduring unwinding chains implicitly, like one inexcept. Areturninfinallyswallows the original exception entirely — Python 3.14 warns about it.- Re-raising with
raise err. Re-raising the same object inside its own handler does not chain;raisealone re-raises with the original traceback, which is usually what you want. - Logging with
exc_info=True. Theloggingmodule prints the full chain for the current exception. Logging inside a handler that then raises something else logs the first and propagates the second. - Exception groups.
ExceptionGrouptracebacks nest sub-exceptions with box-drawing characters; each sub-exception can itself be chained. Read each branch the same way. - Library wrappers hiding causes. Some libraries use
from Noneliberally. When a wrapper hides something you need, inspecterr.__context__in a debugger — it is still there.
Frequently Asked Questions
What does "During handling of the above exception, another exception occurred" mean?
An exception was raised inside an except or finally block while the first exception was being handled. Python records the first one as the second's __context__ and prints both, first one on top. The second exception is the one that propagated.
What is the difference between that message and "The above exception was the direct cause"?
The direct-cause message comes from raise ... from err, which sets __cause__ and says the new exception deliberately wraps the old. The during-handling message comes from implicit chaining and often signals a bug in the handler itself.
When should I use raise from None?
When the original exception is an implementation detail that would only confuse the caller, such as a KeyError from an internal lookup translated into a domain error. It suppresses the context in the printed traceback while keeping it on __context__ for debugging.
Related
- Reading Tracebacks & Exception Chains — frame order, locations and tb styles.
- Getting Useful Tracebacks from Threads and Tasks — chains that cross thread boundaries.
- Post-Mortem Debugging with pdb.pm() — inspecting each link interactively.
- Structured Logging That Survives pytest Capture — logging chained errors.
← Back to Reading Tracebacks & Exception Chains