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.0for the traceback styles discussed here.- Familiarity with
pdbfor 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.
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
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
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
def total(order):
return order.customer.address.postcode # something here is None
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
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
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:
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
| Symptom | Root cause | Fix |
|---|---|---|
| "During handling of the above exception" | A second exception escaped an except block | Fix the handler; it is usually the real bug |
| Chain missing entirely | raise X without from exc inside except | Add from exc, or let the original propagate |
| Traceback points at library code only | Your frames were above the visible window | Read upward; use --tb=long |
| No traceback at all from a worker | threading.Thread swallowed it | threading.excepthook, or use futures |
ExceptionGroup with confusing frames | Several concurrent failures | Read the leaf tracebacks, not the group's |
| Caret markers absent | Running on Python 3.10 or earlier | Upgrade, 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.
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.
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.
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)
]
['/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.
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.
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.
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.
Related guides
- Decode the implicit-chain message in decoding "During handling of the above exception".
- Recover diagnostics from workers with getting useful tracebacks from threads and tasks.
- Escalate into the failing frame using post-mortem debugging with pdb.pm().
- Keep the surrounding context in the failure report via structured logging that survives pytest capture.
- For groups raised by concurrent code, see testing code that uses task groups.