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
- Python 3.11 or later,
pytest >= 8.0. - Background from Reading tracebacks and exception chains.
Solution
# 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
# 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
# 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.
# 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
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:
# 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.
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.
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:
@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
finallyblocks; exceptions there are never reported. Avoid daemon threads for work whose failure matters. executor.maphides later failures. It re-raises the first exception when iteration reaches it and discards the rest. Usesubmitplusas_completedwhen 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)orpytest-timeoutwithtimeout_method = threadto 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.
Related
- Reading Tracebacks & Exception Chains — frame order and chains.
- Decoding "During Handling of the Above Exception" — implicit and explicit chains.
- Diagnosing "Task Was Destroyed" Warnings — lost asyncio tasks.
- Tracking Down a Hung await with Task Stacks — stacks for stuck coroutines.
← Back to Reading Tracebacks & Exception Chains