Ch. 9 · Python

Python Exception Chaining and Diagnostic Context

How raise ... from e, implicit chaining and from None shape a traceback, what __cause__ and __context__ hold, and when add_note is better.

~6 min readintermediateupdated Oct 7, 2026

When you catch a low-level exception and raise a higher-level one, Python keeps the original attached to the new one. The traceback then shows both, separated by one of two sentences, and that sentence tells the reader whether the second error was a deliberate translation or an accident. Getting this right is a small thing that makes production logs far more useful, which is why interviewers ask what raise ... from e does and when you’d use from None.

All the tracebacks below come from Python 3.14. Frames inside the standard library are trimmed to keep them readable.

Implicit chaining: the accidental version

Here’s a config loader that translates a JSON error into a domain error:

import json

class ConfigError(Exception):
    pass

def load_config(text):
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        raise ConfigError("config.json is not valid JSON")

load_config('{"port": 8080,}')
python
Traceback (most recent call last):
  File "ex1.py", line 8, in load_config
    return json.loads(text)
  ...
json.decoder.JSONDecodeError: Illegal trailing comma before end of object: line 1 column 14 (char 13)

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "ex1.py", line 12, in <module>
    load_config('{"port": 8080,}')
  File "ex1.py", line 10, in load_config
    raise ConfigError("config.json is not valid JSON")
ConfigError: config.json is not valid JSON
Text

Python noticed that ConfigError was raised while a JSONDecodeError was being handled and stored the original in the new exception’s __context__ attribute. The phrase “During handling of the above exception, another exception occurred” is what Python prints for that link. It reads like a second, unexpected failure, as if the error handler itself crashed. Someone on call at 3 a.m. might go looking for a bug in the except block.

The same wording appears when an error really is accidental, which is when it’s most valuable. Here a cleanup step fails inside a finally:

def cleanup():
    raise OSError("disk full while writing log")

try:
    1 / 0
finally:
    cleanup()
python
ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  ...
OSError: disk full while writing log
Text

Without implicit chaining, the ZeroDivisionError (the actual first problem) would be lost entirely. That’s why Python does it automatically, and why you shouldn’t try to switch it off: the “another exception occurred” wording is accurate here, because the cleanup really did fail unexpectedly.

Explicit chaining with from

When translating an error is intentional, say so with from:

    except json.JSONDecodeError as e:
        raise ConfigError("config.json is not valid JSON") from e
python

Now the middle of the traceback reads:

json.decoder.JSONDecodeError: Illegal trailing comma before end of object: line 1 column 14 (char 13)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  ...
    raise ConfigError("config.json is not valid JSON") from e
ConfigError: config.json is not valid JSON
Text

“The above exception was the direct cause” tells the reader exactly what happened: the JSON parser failed, and the loader reported that as a config problem. Both pieces of information survive. The caller can catch ConfigError without knowing the loader uses JSON, and the person reading the log still sees the line and column of the stray comma.

Three attributes hold the chain, and you can inspect them in code:

except ConfigError as err:
    print(repr(err.__cause__))
    print(repr(err.__context__))
    print(err.__suppress_context__)
python
JSONDecodeError('Illegal trailing comma before end of object: line 1 column 14 (char 13)')
JSONDecodeError('Illegal trailing comma before end of object: line 1 column 14 (char 13)')
True
Text

__cause__ is set by from. __context__ is always set when you raise inside an except (so it’s set here too). __suppress_context__ is set to True by any from clause, and tells the traceback printer to show the cause rather than the context. Error-reporting tools like Sentry walk these attributes, so a proper chain shows up there as well.

Where should translation happen? At layer boundaries. A storage module that uses SQLite internally can translate sqlite3.IntegrityError into DuplicateOrder so the service layer never imports sqlite3. If you later move to PostgreSQL, callers don’t change, and thanks to from e the log still shows the driver’s message. Translating at every function, on the other hand, produces chains five levels deep that say the same thing in five vocabularies. One translation per boundary is usually enough. Interviewers sometimes push on this with “why not just let the original exception propagate?” A fair answer: you can, inside a layer, but exposing a driver’s exception types in your public API couples every caller to that driver.

Hiding the original with from None

Sometimes the original exception is noise. A lookup helper that turns a KeyError into a clearer message is a good example:

def get_port(settings):
    try:
        return int(settings["port"])
    except KeyError:
        raise LookupError("missing required setting 'port'") from None

get_port({})
python
Traceback (most recent call last):
  File "ex2.py", line 6, in <module>
    get_port({})
  File "ex2.py", line 5, in get_port
    raise LookupError("missing required setting 'port'") from None
LookupError: missing required setting 'port'
Text

The KeyError: 'port' is gone because it added nothing the new message doesn’t say. from None sets __suppress_context__ without setting a cause. The standard library uses this pattern in places where the internal exception is an implementation detail.

The misconception to avoid is treating from None as a way to “clean up” tracebacks generally. For anything involving I/O, parsing, or a third-party library, the original exception usually contains the detail you’ll need: an errno, a SQL state, a byte offset. Suppressing it trades a slightly shorter log for a much longer investigation. A sensible default: from e when translating, from None only when the original is a simple lookup failure you’ve fully described.

Adding context without changing the type

Python 3.11 added BaseException.add_note(). It attaches extra lines to an exception’s traceback without wrapping it in a new type. That’s useful when the existing type is already correct and callers catch it by type, but the message lacks context:

def parse_rows(rows):
    for n, row in enumerate(rows, start=1):
        try:
            yield int(row)
        except ValueError as e:
            e.add_note(f"while parsing row {n} of orders.csv")
            raise

list(parse_rows(["10", "x7"]))
python
  File "ex3.py", line 16, in parse_rows
    yield int(row)
ValueError: invalid literal for int() with base 10: 'x7'
while parsing row 2 of orders.csv
Text

The caller still catches ValueError and nothing else changed, but the log now says which row failed. Before 3.11, people did this by building a new exception with a longer message, which often lost the original type or traceback.

Two related habits come up in reviews. Re-raise with a bare raise, not raise e, inside an except block; the bare form makes the intent clear and doesn’t add the current line as an extra traceback frame. And when logging a caught exception, use logger.exception("...") (or exc_info=True), which prints the whole chain. logger.error(str(e)) keeps only the last message and throws away everything this article is about.

Interview exercise: the useless payment error

A payment service wraps every gateway call in try: ... except Exception: raise PaymentFailed("payment failed") from None. Support tickets say “payment failed” and engineers can’t tell timeouts from card declines from bugs. What would you change?

Answer

from None is throwing away the only evidence: the gateway’s timeout, the HTTP status, the decline code. Change it to from e so the cause appears in logs and in error-tracking tools, which costs nothing for callers who still catch PaymentFailed. Then narrow the handler: except Exception also catches AttributeError and TypeError from bugs in our own code, which should crash loudly rather than look like a declined card. Catch the gateway’s specific exceptions instead. Finally, carry the useful parts as data, not just text: subclasses such as PaymentDeclined and GatewayTimeout, or attributes like decline_code, so callers can decide whether to retry, and log with logger.exception at the boundary where the error is finally handled.

See also

More in Python

esc