RuntimeError: request failed is a log line that tells you a thing broke and
nothing else. The same failure with a __cause__ of
ConnectionResetError: [Errno 104] Connection reset by peer tells you which
layer broke, whether it was your fault, and whether it is worth retrying. The
difference between those two log lines is four characters: from.
Python gives you three distinct chaining behaviours and one of them happens whether you ask for it or not. Knowing which is which is the difference between tracebacks that answer questions and tracebacks that generate them.
Implicit: __context__
Raise anything while you are handling something else and the interpreter silently records what you were handling:
try:
value = data["user"]
except KeyError:
raise RuntimeError("no user in payload")
The traceback shows the KeyError, then the line
During handling of the above exception, another exception occurred:
then the RuntimeError. You get this for free. __context__ is set to the
KeyError, __cause__ is None.
Free is not the same as good. __context__ fires for accidental nesting too —
an exception raised inside a finally, or a cleanup that fails while you are
already unwinding, gets chained to something it has no causal relationship with.
The wording of the message is a hint: “during handling of” is a statement about
time, not about cause.
Explicit: __cause__
except KeyError as exc:
raise RuntimeError("no user in payload") from exc
Now __cause__ is the KeyError, and the traceback says
The above exception was the direct cause of the following exception:
This is the one you want when you translate an error across a boundary, and it is the one every error tracker and log aggregator follows when it builds a chain. It is also a claim: this happened because that happened. Make it when it is true.
Note that raising from exc inside an except exc block sets both
attributes to the same object — the explicit cause and the implicit context
coincide. That is expected, not a bug, and it is worth checking once in a REPL
so it never surprises you in a debugger.
💡__context__ is set automatically. Why does __cause__ exist at all, if the chain is already there?
click to reveal
Three reasons, in increasing order of importance.
Display. Python prints __cause__ chains with “was the direct cause of” and __context__ chains with “during handling of”. Readers of your traceback treat those very differently, and correctly so.
Suppression. Setting __cause__ (even to None, via from None) sets __suppress_context__, which is how you say “ignore the implicit chain”. Without an explicit mechanism there would be no way to opt out of the automatic one.
Truth. __context__ means “these were in flight at the same time”. __cause__ means “this one caused that one”. Those are different claims and only you know which is true. A cleanup failure inside a finally while another exception is unwinding gets a __context__ and deserves no __cause__ at all — the two failures are unrelated, they merely collided.
Suppressed: from None
except KeyError as exc:
raise ConfigError("missing key 'user' in config") from None
The chain is hidden. __context__ is still set on the object — you can still
find it in a debugger — but the traceback does not print it.
This is correct in exactly two situations:
-
The original leaks an implementation detail. Your config loader happens
to use
tomllibtoday; atomllib.TOMLDecodeErrorin your public traceback is an invitation for someone toexceptit, and then you can never switch parsers. -
The original contains a secret. A
KeyErrorwhose key is an API token, a database driver error echoing the connection string with the password in it. Suppress it, log the redacted version, move on.
It is not correct because the traceback is long, or ugly, or because you find
the nesting confusing. Every from None you write for aesthetic reasons is a
future incident where the only diagnostic information was thrown away by a
previous version of you.
Bare raise: the one that preserves the traceback
Inside an except block, a bare raise re-raises the exception you are
handling with its original traceback intact:
except TimeoutError:
metrics.increment("timeouts")
raise
Writing raise exc instead re-raises the same object but appends the current
line to the traceback, so the innermost frame now looks like it is in your
logging helper. It is a small loss of information that becomes a large one when
the helper is three layers up from where the failure actually happened.
Rule: inside except, if you are not changing the exception, use a bare
raise.
add_note: context you can attach later
Python 3.11 added BaseException.add_note:
for record in records:
try:
handle(record)
except Exception as exc:
exc.add_note(f"record={record.id}")
raise
Notes are appended to __notes__ and printed after the exception message.
They are the fix for a specific, common problem: the loop variable that knew
which record failed is out of scope by the time the exception reaches the
handler that logs it. Rather than wrapping the exception in a new type — which
changes what callers can catch — you annotate the one you already have.
Notes survive travelling inside an ExceptionGroup, and are copied onto derived
groups by split() and subgroup(), so context attached deep in a worker
arrives intact at the top of a TaskGroup.
💡You are writing the retry layer. On the final failure, do you re-raise the last exception, wrap it in RetryExhaustedError from it, or raise a fresh error with from None?
click to reveal
Wrap it, chained: raise RetryExhaustedError(f"gave up after {n} attempts") from last_exc.
Bare re-raise loses the policy information. The caller sees a ConnectionResetError and has no idea whether that was one attempt or five, or whether your budget was exhausted or you never retried at all. Retry behaviour is exactly what the caller wants to reason about.
from None loses the diagnosis. RetryExhaustedError: gave up after 5 attempts does not tell anyone what kept failing, which is the only actionable part.
Chaining gives both: the class says the policy gave up, the cause says what it gave up on. And if you want the caller to be able to branch on the underlying failure, they can — exc.__cause__ is right there, typed BaseException | None, which mypy will make them narrow before use.
One refinement worth adding in production: add_note() the attempt count and the elapsed time on the way out. That way the information survives even if some middle layer re-wraps your error again.
Typing the chain
Two small things mypy --strict will hold you to.
from accepts a BaseException | None, so raise X from exc and
raise X from None are both fine, but raise X from "some string" is not — the
chain is objects, not prose.
Reading the chain gives you BaseException | None:
cause = exc.__cause__
if cause is not None:
log.error("caused by %s", type(cause).__name__)
The is not None is not defensive noise; there is genuinely no cause on most
exceptions, and the checker will not let you forget. Note the %s there, too —
in logging calls, %-style placeholders are correct and recommended, because
the formatting only happens if the record is actually emitted. A blanket
migration of logging calls to f-strings is a regression.
The one-paragraph summary
Chain explicitly when you translate (from exc). Suppress only to hide an
implementation detail or a secret (from None). Re-raise with a bare raise
when you are only observing. Annotate in flight with add_note. And when you
read a traceback, notice which of the two sentences the interpreter printed —
“was the direct cause of” means somebody made a claim, “during handling of”
means the interpreter is just telling you what else was going on.