Debugging & Performance

Getting Useful Tracebacks from Threads and Tasks

A traceback from the main thread tells you what went wrong. A traceback from a background thread often tells you nothing at all, because it never reaches you. An exception in a threading.Thread target ends that thread and prints to stderr, where it scrolls past between log lines; the main thread carries on. An exception in a ThreadPoolExecutor task is stored on its Future and vanishes if nobody asks for the result. An exception in a fire-and-forget asyncio task sits on the task until it is garbage-collected, when a Task exception was never retrieved message appears — possibly minutes later and far from the cause.

In tests the effect is worse than in production. A test that starts a worker thread, triggers an operation and asserts on a side effect can pass while the worker crashed, or fail with a timeout that says nothing about the real error. The fix is to decide, for each kind of concurrency, where exceptions should go, and then make sure they arrive there with their tracebacks intact.

Prerequisites

Solution

Python
# Executors — always collect results so exceptions re-raise in the caller.
from concurrent.futures import ThreadPoolExecutor, as_completed

with ThreadPoolExecutor(max_workers=8) as pool:
    futures = {pool.submit(fetch, url): url for url in urls}
    for fut in as_completed(futures):
        data = fut.result()           # re-raises the worker's exception with its traceback
Python
# Raw threads — capture uncaught exceptions and fail loudly.
import threading

errors: list[threading.ExceptHookArgs] = []

def record(args: threading.ExceptHookArgs) -> None:
    errors.append(args)
    threading.__excepthook__(args)    # still print the traceback

threading.excepthook = record
Python
# asyncio — structured concurrency propagates task exceptions.
import asyncio

async def main():
    async with asyncio.TaskGroup() as tg:
        tg.create_task(consume(queue))
        tg.create_task(produce(queue))
    # Any task's exception cancels the others and raises an ExceptionGroup here.
Python
# Anything hung — dump every thread's stack on demand.
import faulthandler, signal
faulthandler.register(signal.SIGUSR1, all_threads=True)
# kill -USR1 <pid>  → stacks of all threads on stderr
Where background exceptions go by default Three sources of background exceptions and their default destinations. A raw thread's exception goes to threading.excepthook and is printed to stderr. An executor task's exception is stored on the Future and lost unless result is called. An asyncio task's exception is stored on the task and reported only at garbage collection. The fixes are shown alongside: a recording excepthook, calling result, and TaskGroup. Default destination, and the fix source by default make it surface threading.Thread printed to stderr, thread dies recording excepthook executor.submit stored on Future, maybe lost future.result() asyncio.create_task reported at GC, if ever TaskGroup or await
None of the defaults propagate to the code that started the work; every fix is about bringing the exception back to it.

Why this works

Exceptions propagate up a single call stack. A thread has its own stack, so when its target raises, the exception unwinds to the top of that stack and stops. Python hands it to threading.excepthook, whose default prints the traceback and returns. The starting thread is on a different stack entirely and never sees it.

Futures bridge the gap by storing the exception object — traceback included — and re-raising it when result() is called on the other side. That re-raise is the propagation; skipping it is equivalent to catching and ignoring the exception. as_completed and wait are just ways of deciding the order in which to call result().

asyncio tasks work the same way: a task's exception is stored and re-raised when the task is awaited. TaskGroup awaits all its tasks on exit and, if any failed, cancels the rest and raises an ExceptionGroup containing every failure with its own traceback. That is structured concurrency: work started inside a scope cannot outlive it, so its exceptions cannot escape unobserved.

Threads in pytest

pytest installs its own threading.excepthook during each test. When a thread raises, pytest records the exception and emits PytestUnhandledThreadExceptionWarning with the traceback attached. A warning does not fail the test by default, so promote it:

TOML
# pyproject.toml
[tool.pytest.ini_options]
filterwarnings = [
    "error::pytest.PytestUnhandledThreadExceptionWarning",
    "error::pytest.PytestUnraisableExceptionWarning",
]

With that, any test during which a background thread crashes fails, and the failure shows the thread's traceback. The second entry does the same for "unraisable" exceptions — errors in __del__ methods and garbage-collection callbacks, which is also where a never-retrieved asyncio task exception usually surfaces.

pytest turning a thread crash into a failure A test starts a worker thread. The worker raises. pytest's excepthook records the exception and emits a warning. With filterwarnings set to error, the warning becomes a test failure that includes the worker's traceback, instead of a passing test with a line on stderr. From a line on stderr to a red test worker raises pytest excepthook records traceback warning emitted Unhandled Thread Exception test fails filterwarnings = error Without the filter, the same crash leaves the test green.
Two lines of configuration turn every silent background crash in the suite into a failure with a traceback attached.

Reading a traceback that crossed a thread boundary

When future.result() re-raises a worker's exception, the traceback printed in the caller has an unusual shape, and misreading it sends people to the wrong frame. It contains two stacks stitched together. The upper part shows the caller's frames down to the result() call; below that come frames from concurrent/futures/_base.py, where the stored exception is re-raised; and below those, the worker's own frames, ending at the line that actually failed.

Only the last group is where the bug lives. The caller's frames explain who asked for the result — useful context, but not the cause. The library frames in between are plumbing and can be skipped. Python 3.11's fine-grained error locations help here: the ^^^^ markers under the failing expression appear only on the worker's final frame, which makes it easy to spot.

ProcessPoolExecutor adds one more twist. The worker's exception is pickled and sent back to the parent process, and its original traceback cannot cross the process boundary as live frames. Python attaches the formatted remote traceback as a _RemoteTraceback in the exception's __cause__, so the output shows the remote stack as text under "The above exception was the direct cause of the following exception". Read that block for the real location; the local stack only shows where the result was collected.

asyncio tasks awaited normally are simpler, because the exception propagates through await on the same thread and the frames join naturally. With TaskGroup, each failure appears as a sub-exception inside an ExceptionGroup, drawn with box characters; each sub-exception carries its own complete traceback from its own task.

Anatomy of a re-raised worker traceback A traceback re-raised by future.result has three segments from top to bottom: caller frames down to the result call, concurrent.futures library frames that re-raise the stored exception, and the worker's frames ending at the failing line, which is where the bug is. For process pools, the worker segment appears as a remote traceback in the exception's cause. Skip the plumbing, read the worker's frames caller frames … data = fut.result() concurrent/futures/_base.py — re-raise plumbing worker frames fetch() → parse() → the failing line ^^^^ who asked skip the bug Process pools: worker frames arrive as a _RemoteTraceback in __cause__.
The failing line is always at the bottom of the worker segment, marked by the fine-grained location carets.

A fixture that fails on background crashes

The pytest warning filter covers threading.Thread, but code that uses executors without collecting results still loses exceptions. A small autouse fixture in the affected test module can close that gap by patching the executor to track futures and check them at teardown:

Python
@pytest.fixture(autouse=True)
def no_lost_futures(monkeypatch):
    seen = []
    real_submit = ThreadPoolExecutor.submit
    def submit(self, *a, **kw):
        fut = real_submit(self, *a, **kw)
        seen.append(fut)
        return fut
    monkeypatch.setattr(ThreadPoolExecutor, "submit", submit)
    yield
    for fut in seen:
        if fut.done() and fut.exception() is not None:
            raise fut.exception()

It is blunt, and it belongs in tests rather than production code, but it turns every forgotten result() into a failing test with the worker's traceback — which is usually enough to persuade the code's owner to collect results properly.

Production: making background failures visible

Outside tests, the goal shifts from failing to alerting. A background worker that dies silently in a service is worse than one that crashes the process, because the service keeps answering health checks while a queue stops being drained. Three habits keep that from happening.

Install a process-wide threading.excepthook that logs through the application's logger with exc_info set, so thread crashes reach the same aggregation system as request errors instead of a stderr stream nobody reads. For asyncio, set loop.set_exception_handler to do the same for exceptions in callbacks and never-retrieved tasks. And for long-lived workers, supervise them: a small loop that restarts a crashed worker and increments a metric makes the crash visible on a dashboard and keeps the service functional while someone investigates.

None of these replace structured concurrency where it fits. They are the safety net for code that genuinely needs detached background work — a metrics flusher, a cache warmer — where no caller is waiting for a result.

Edge cases and failure modes

  • Exceptions after the test ends. A thread that crashes after its test finishes is attributed to whichever test is running then. Join threads in fixture teardown so crashes land in the right test.
  • Daemon threads at shutdown. Daemon threads are killed at interpreter exit without running their finally blocks; exceptions there are never reported. Avoid daemon threads for work whose failure matters.
  • executor.map hides later failures. It re-raises the first exception when iteration reaches it and discards the rest. Use submit plus as_completed when every failure matters.
  • gather(return_exceptions=True). Exceptions come back as values in the result list; code that ignores the list ignores the errors.
  • Hard hangs. If a thread is deadlocked rather than crashed, there is no exception. Use faulthandler.dump_traceback_later(timeout) or pytest-timeout with timeout_method = thread to get stacks from every thread.

Frequently Asked Questions

Why did an exception in my thread not fail the test? An exception in a threading.Thread ends that thread and is passed to threading.excepthook, which prints it to stderr. It never propagates to the thread that started it, so the test continues and may pass. pytest reports it as a PytestUnhandledThreadExceptionWarning.

Where does an exception in a ThreadPoolExecutor task go? It is stored on the Future and re-raised when you call future.result(). If nothing calls result(), the exception is silently discarded when the Future is garbage collected.

How do I see where every thread is stuck? Call faulthandler.dump_traceback(all_threads=True), or register it on a signal with faulthandler.register(signal.SIGUSR1). It prints the current stack of every thread without needing the process to cooperate.

← Back to Reading Tracebacks & Exception Chains