Debugging & Performance

Reading Tracebacks & Exception Chains

A traceback contains everything needed to diagnose most failures, and most of it goes unread. Engineers scan for a familiar exception name, look at one line, and start guessing. The habits that replace guessing are mechanical — read bottom-up, follow the chain markers, narrow with the caret — and they turn a ten-minute hunt into a twenty-second read.

Prerequisites

  • Python 3.11+ for fine-grained error locations and ExceptionGroup; the rest applies to 3.9 onward.
  • pytest >= 8.0 for the traceback styles discussed here.
  • Familiarity with pdb for the cases a traceback alone cannot resolve — see interactive debugging with pdb and ipdb.

Core concept: the shape of a traceback

A traceback is a stack, printed outermost first. The top frame is where execution entered, the bottom frame is where the exception was raised, and the last line is the exception itself. Chained exceptions print as several such stacks separated by a marker that says how they relate.

Anatomy of a chained traceback A traceback printed in two blocks. The upper block is the original exception with its own frames. A separator line states either that it was the direct cause, from an explicit raise-from, or that it occurred during handling, from an implicit context. The lower block is the exception that actually escaped, with the exception type and message on the final line. Read the last line, then the bottom block, then upward Traceback (most recent call last): File "app/http.py", line 44, in fetch_json File "json/decoder.py", line 355, in raw_decode JSONDecodeError The above exception was the direct cause of the following exception Traceback (most recent call last): File "app/api.py", line 18, in get_invoice ← your code File "app/http.py", line 47, in fetch_json UpstreamError: billing returned malformed JSON
Two blocks, one separator. The separator's wording is the single most informative token in the whole output, because it says whether the conversion was deliberate.

Two separators exist and they mean different things. "The above exception was the direct cause" comes from raise X from Y and marks a deliberate conversion — somebody decided the caller should see a domain error. "During handling of the above exception, another exception occurred" is implicit: a second exception escaped from inside an except block. The second usually means the handler is broken, which is a different and often more embarrassing bug than the original.

Step-by-step implementation

1. Chain deliberately when converting

Python
import json

from myapp.errors import UpstreamError


def fetch_json(url: str) -> dict:
    body = _get(url)
    try:
        return json.loads(body)
    except json.JSONDecodeError as exc:
        # `from exc` sets __cause__: the original traceback is preserved and
        # the printed separator says the conversion was intentional.
        raise UpstreamError(f"{url} returned malformed JSON") from exc

2. Suppress a chain only when the original is genuinely noise

Python
import os

from myapp.errors import ConfigError


def load_port() -> int:
    try:
        return int(os.environ["PORT"])
    except (KeyError, ValueError):
        # `from None` clears __context__: the caller sees one clean error
        # rather than a KeyError traceback that adds nothing.
        raise ConfigError("PORT must be set to an integer") from None

from None is the right call when the internal exception carries no information the caller can act on. It is the wrong call when it does — suppressing a ConnectionResetError behind a generic ServiceUnavailable removes the one detail an operator needed.

3. Read the caret markers

Python
def total(order):
    return order.customer.address.postcode        # something here is None
Plain text
Traceback (most recent call last):
  File "app/billing.py", line 12, in total
    return order.customer.address.postcode
           ^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'postcode'

Before 3.11 this said only that something on the line was None. The carets say it was order.customer.address — three attribute accesses in, so customer exists and address is None. On a line with several calls, that distinction saves the entire investigation.

4. Choose a traceback style per situation

Bash
pytest --tb=short -q          # CI: one frame per file, readable in volume
pytest --tb=long              # diagnosing: full frames with source
pytest --tb=line              # a sweep: one line per failure
pytest --showlocals --tb=long # when the values matter more than the path
pytest --tb=native            # exactly what Python would print

--showlocals is underused. A failure whose traceback is obvious but whose cause is not — "why was that list empty?" — is usually answered immediately by the locals in the frame, with no re-run needed.

5. Capture tracebacks from threads and tasks

Python
import threading


def test_worker_exceptions_reach_the_test():
    errors: list[BaseException] = []

    def hook(args: threading.ExceptHookArgs) -> None:
        errors.append(args.exc_value)         # a raw Thread otherwise swallows it

    original, threading.excepthook = threading.excepthook, hook
    try:
        thread = threading.Thread(target=broken_worker)
        thread.start()
        thread.join(timeout=5)
    finally:
        threading.excepthook = original

    assert not errors, errors[0]

An exception in a threading.Thread prints to stderr and is then discarded; the test passes. threading.excepthook (3.8+) is the standard-library answer, and ThreadPoolExecutor with future.result() is the simpler one where it applies.

Verification

Check that chains survive your own error handling, which is where they are most often lost:

Python
import pytest


def test_upstream_error_preserves_the_cause():
    with pytest.raises(UpstreamError) as excinfo:
        fetch_json("https://billing.test/bad")

    # The chain is part of the contract: a caller logging exc.__cause__
    # must get the decoding error, not None.
    assert isinstance(excinfo.value.__cause__, json.JSONDecodeError)
    assert excinfo.value.__suppress_context__ is False

Asserting on __cause__ looks fussy until the first time somebody "tidies" a raise ... from exc into a bare raise UpstreamError(...) and every production traceback loses its origin.

Troubleshooting

SymptomRoot causeFix
"During handling of the above exception"A second exception escaped an except blockFix the handler; it is usually the real bug
Chain missing entirelyraise X without from exc inside exceptAdd from exc, or let the original propagate
Traceback points at library code onlyYour frames were above the visible windowRead upward; use --tb=long
No traceback at all from a workerthreading.Thread swallowed itthreading.excepthook, or use futures
ExceptionGroup with confusing framesSeveral concurrent failuresRead the leaf tracebacks, not the group's
Caret markers absentRunning on Python 3.10 or earlierUpgrade, or split the expression across lines

Exception groups and except*

Task groups change the shape of what arrives. When several children fail concurrently, the block raises an ExceptionGroup containing all of them, and the printed traceback nests one sub-traceback per contained exception.

Python
import asyncio

try:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch("/a"))
        tg.create_task(fetch("/b"))
except* TimeoutError as group:
    # `group` is itself an ExceptionGroup containing only the TimeoutErrors.
    for exc in group.exceptions:
        logger.warning("timed out: %s", exc)
except* ValueError as group:
    # Both handlers can run for the same original group.
    raise UpstreamError("bad payload") from group

Two things about except* catch people out. It always binds an ExceptionGroup, even when exactly one exception matched, so group.exceptions is the thing to iterate rather than group itself. And several except* clauses can run for a single raised group, unlike ordinary except where the first match wins — the group is split by type and each matching clause receives its share.

Reading the printed form follows the same rule as any chain: the outermost frames describe where the group was assembled, which is almost never interesting, and the leaves describe the actual failures. A group with three leaves and one distinct message is usually one bug hit by three concurrent requests; a group with three different messages is usually three bugs, or one bug plus two cancellations.

Structure of an ExceptionGroup traceback An outer exception group frame from the task group block contains three nested sub-tracebacks. Two are timeout errors from the same call site and one is a value error from a different one. A note indicates that the leaves carry the diagnosis while the outer frames only show where the group was assembled. The diagnosis is in the leaves ExceptionGroup: unhandled errors in a TaskGroup (3 sub-exceptions) File "app/sync.py", line 61, in refresh_all — where the group was assembled +---------------- 1 ---------------- TimeoutError: /a after 5s +---------------- 2 ---------------- TimeoutError: /b after 5s +---------------- 3 ---------------- ValueError: malformed payload Two identical leaves and one different: one timeout bug hit twice, plus a separate parsing failure.
Counting distinct leaf messages is the fastest triage available on a group — it separates "one bug, many requests" from "several unrelated failures".

Frames, and how far up to read

The mechanical skill worth practising is deciding which frame to look at. A traceback through a web framework, an ORM and a driver can be forty frames deep, of which three are yours.

The reliable procedure is to scan upward from the bottom for the first frame whose file path is inside your project. That frame is where your code handed control to somebody else's, and the bad value almost always originated there or above it. Library frames below it are usually correct code correctly rejecting bad input.

Python
import traceback


def project_frames(exc: BaseException, root: str = "/app/") -> list[str]:
    """The frames that are yours, in the order they appear."""
    return [
        f"{frame.filename}:{frame.lineno} in {frame.name}"
        for frame in traceback.extract_tb(exc.__traceback__)
        if frame.filename.startswith(root)
    ]
Plain text
['/app/api.py:18 in get_invoice', '/app/http.py:47 in fetch_json']

Two frames instead of forty, and the second is where to start. Logging this alongside the full traceback makes production errors triageable at a glance, and the same filter drives the --tb=short style pytest offers.

Two exceptions to the "read your own frames" rule are worth knowing. A TypeError deep inside a library often means you passed the wrong type several frames up, and the library frame names the parameter — useful information that the filter discards. And an exception raised inside a C extension may have no Python frame for the actual failure at all, in which case the deepest Python frame is the call that entered the extension and the investigation moves to its arguments.

Tracebacks that cross a process boundary

A traceback is a live object graph referencing code objects and frames, and none of that survives being sent to another process. What arrives is a formatted string, or nothing.

Python
import concurrent.futures
import traceback


def worker(payload):
    try:
        return process(payload)
    except Exception as exc:
        # Format here, where the frames still exist; the parent gets text.
        raise RuntimeError(
            f"worker failed on {payload['id']}:\n{traceback.format_exc()}"
        ) from None


with concurrent.futures.ProcessPoolExecutor() as pool:
    future = pool.submit(worker, {"id": "a"})
    result = future.result()        # re-raises, with the child's text attached

concurrent.futures pickles the exception and re-raises it in the parent, which preserves the type and message but not the child's frames — so the parent's traceback shows the future.result() call and nothing about where the work actually failed. Formatting in the child and carrying the text is the standard workaround; tblib automates it if the volume justifies a dependency.

The same applies to pytest-xdist, which does this for you: a failure in a worker is serialized and reported by the controller with the worker's formatted traceback. When a traceback from an xdist run looks truncated, it is usually because the failure was in worker setup rather than a test, and the mechanics of isolating those are in debugging a test that only fails under xdist.

Making your own exceptions readable

Half of traceback quality is decided when the exception is defined rather than when it is read.

Put the values in the message. InvalidInvoice("total 1234 does not match lines summing to 1200") answers the next question; InvalidInvoice("invalid invoice") guarantees a debugging session. Include the identifiers needed to find the record, and never include credentials or personal data, which end up in logs.

Give the exception attributes, not just a string. exc.invoice_id lets a handler act; a formatted message forces it to parse prose.

Do not catch and re-raise for no reason. except Exception as e: raise MyError(str(e)) destroys the traceback, drops the type, and adds nothing. If the conversion is worth making, use from exc; if it is not, let the original propagate.

Keep __str__ cheap and total. An exception whose __str__ queries a database or raises on a missing attribute turns a simple failure into an unprintable one, and the resulting "exception while printing exception" is among the least pleasant outputs Python produces.

Python
class InvalidInvoice(Exception):
    def __init__(self, invoice_id: str, expected: int, actual: int) -> None:
        self.invoice_id = invoice_id
        self.expected = expected
        self.actual = actual
        super().__init__(
            f"invoice {invoice_id}: total {actual} != sum of lines {expected}"
        )

Tracebacks in a test suite

pytest rewrites the presentation but not the substance, and a few habits make its output do more work.

The --tb style should differ by context, as above. --showlocals belongs in CI for a suite where failures are rare and expensive to reproduce; it is noisy in a local loop. And -r a at the end of a run prints a short reason for every non-passing outcome, which is the fastest way to see that twelve "failures" are actually one error and eleven cascading skips.

For failures inside fixtures, pytest distinguishes an error from a failure, and the distinction is worth reading rather than skimming: an error means setup or teardown raised, so the test body never ran and the traceback is about the fixture. Chasing a test body for a bug that is in a fixture is a common waste of ten minutes, and the report says which it was on the first line.

Where the traceback genuinely is not enough, the next step is a post-mortem debugger session in the failing frame — --pdb drops straight into it with all locals intact, as described in post-mortem debugging with pdb.pm(). That is the right escalation once reading has been exhausted, and it is much faster than adding prints and re-running.

A reading order that works

Put the pieces together and the procedure is four steps, in this order, every time.

Four-step reading order for any traceback A sequence. First read the final line for the exception type and message. Second, identify the chain separators and pick the block that escaped. Third, scan upward from the bottom frame to the first frame inside the project. Fourth, use the caret markers or showlocals to narrow to the sub-expression or value, escalating to a post-mortem debugger only if the value is still unexplained. What to look at, in order 1 · last line exception type and message what went wrong 2 · separators direct cause, or during handling? which block escaped 3 · your frame scan upward to the first project file where to start 4 · narrow carets and showlocals which value Still unexplained after step four? That is the point to escalate to a post-mortem debugger, not before — the four steps resolve the large majority of failures in under a minute.
The order matters: reading frames before reading the message is how people end up debugging the wrong exception in a chained pair.

The step most often skipped is the second. In a chained traceback the eye is drawn to the first block because it is printed first, but the exception that actually escaped is the last one, and the first block may be entirely irrelevant — a handled KeyError inside a cache lookup, for example, that is only visible because a later, unrelated exception dragged its context along.

A useful habit for production code follows from that: wherever an except block does real work — retries, cleanup, fallback logic — wrap the risky part of the handler in its own try so a failure there is reported as its own error rather than as an implicit chain on top of whatever it was handling. The resulting log is two clear entries instead of one confusing composite, and the on-call engineer reading it at three in the morning does not have to work out which half matters.

Frequently Asked Questions

What does 'During handling of the above exception, another exception occurred' mean? A second exception was raised while an except block was handling the first, and Python printed both. The lower traceback is the one that escaped; the upper one is the original cause. It is implicit chaining via __context__, and it usually means the error handler itself is broken — the second exception is often a bug in logging or cleanup rather than the real failure.

When should I use raise ... from? Whenever you convert a low-level exception into a domain one. raise InvalidInvoice(...) from exc sets __cause__, which prints "The above exception was the direct cause" and preserves the original traceback. Use from None to suppress a noisy internal exception deliberately, and never leave the conversion unqualified when the original matters.

Why does the traceback point at a line that looks fine? Usually because the frame shown is the one that raised, not the one that caused the bad value. Read upward: the topmost frame is the entry point and the bottom is where the exception was raised, so the defect is often in a caller that passed something wrong. From Python 3.11 the caret markers narrow it to a sub-expression, which resolves most of these immediately.

How do I see the traceback from another thread or task? Threads need threading.excepthook or a wrapper that captures and re-raises in the caller; a raw Thread prints to stderr and does not fail the test. For asyncio, an un-retrieved task exception is reported by the loop's exception handler at garbage-collection time, so awaiting or gathering the task is what surfaces it in the right place.

What is an ExceptionGroup and how do I read one? It is a container raised by task groups and by code using except* when several exceptions occur concurrently. The traceback is nested: the outer frames are the group's, and each contained exception has its own traceback indented beneath. Read the leaves; the outer frames only tell you where the group was assembled.

← Back to Systematic Debugging & Performance Profiling