[{"data":1,"prerenderedAt":1003},["ShallowReactive",2],{"page-\u002Fsystematic-debugging-performance-profiling\u002Freading-tracebacks-and-exception-chains\u002Fgetting-useful-tracebacks-from-threads-and-tasks\u002F":3},{"id":4,"title":5,"body":6,"description":969,"extension":970,"meta":971,"navigation":89,"path":999,"seo":1000,"stem":1001,"__hash__":1002},"content\u002Fsystematic-debugging-performance-profiling\u002Freading-tracebacks-and-exception-chains\u002Fgetting-useful-tracebacks-from-threads-and-tasks\u002Findex.md","Getting Useful Tracebacks from Threads and Tasks",{"type":7,"value":8,"toc":957},"minimark",[9,30,33,38,57,61,115,170,214,239,349,353,365,382,393,397,407,444,451,564,568,581,592,606,619,690,694,700,773,779,783,786,800,803,807,868,872,886,901,915,919,948,953],[10,11,12,13,17,18,21,22,25,26,29],"p",{},"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 ",[14,15,16],"code",{},"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 ",[14,19,20],{},"ThreadPoolExecutor"," task is stored on its ",[14,23,24],{},"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 ",[14,27,28],{},"Task exception was never retrieved"," message appears — possibly minutes later and far from the cause.",[10,31,32],{},"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.",[34,35,37],"h2",{"id":36},"prerequisites","Prerequisites",[39,40,41,49],"ul",{},[42,43,44,45,48],"li",{},"Python 3.11 or later, ",[14,46,47],{},"pytest >= 8.0",".",[42,50,51,52,48],{},"Background from ",[53,54,56],"a",{"href":55},"\u002Fsystematic-debugging-performance-profiling\u002Freading-tracebacks-and-exception-chains\u002F","Reading tracebacks and exception chains",[34,58,60],{"id":59},"solution","Solution",[62,63,68],"pre",{"className":64,"code":65,"language":66,"meta":67,"style":67},"language-python shiki shiki-themes github-light github-dark","# Executors — always collect results so exceptions re-raise in the caller.\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\n\nwith ThreadPoolExecutor(max_workers=8) as pool:\n    futures = {pool.submit(fetch, url): url for url in urls}\n    for fut in as_completed(futures):\n        data = fut.result()           # re-raises the worker's exception with its traceback\n","python","",[14,69,70,78,84,91,97,103,109],{"__ignoreMap":67},[71,72,75],"span",{"class":73,"line":74},"line",1,[71,76,77],{},"# Executors — always collect results so exceptions re-raise in the caller.\n",[71,79,81],{"class":73,"line":80},2,[71,82,83],{},"from concurrent.futures import ThreadPoolExecutor, as_completed\n",[71,85,87],{"class":73,"line":86},3,[71,88,90],{"emptyLinePlaceholder":89},true,"\n",[71,92,94],{"class":73,"line":93},4,[71,95,96],{},"with ThreadPoolExecutor(max_workers=8) as pool:\n",[71,98,100],{"class":73,"line":99},5,[71,101,102],{},"    futures = {pool.submit(fetch, url): url for url in urls}\n",[71,104,106],{"class":73,"line":105},6,[71,107,108],{},"    for fut in as_completed(futures):\n",[71,110,112],{"class":73,"line":111},7,[71,113,114],{},"        data = fut.result()           # re-raises the worker's exception with its traceback\n",[62,116,118],{"className":64,"code":117,"language":66,"meta":67,"style":67},"# Raw threads — capture uncaught exceptions and fail loudly.\nimport threading\n\nerrors: list[threading.ExceptHookArgs] = []\n\ndef record(args: threading.ExceptHookArgs) -> None:\n    errors.append(args)\n    threading.__excepthook__(args)    # still print the traceback\n\nthreading.excepthook = record\n",[14,119,120,125,130,134,139,143,148,153,159,164],{"__ignoreMap":67},[71,121,122],{"class":73,"line":74},[71,123,124],{},"# Raw threads — capture uncaught exceptions and fail loudly.\n",[71,126,127],{"class":73,"line":80},[71,128,129],{},"import threading\n",[71,131,132],{"class":73,"line":86},[71,133,90],{"emptyLinePlaceholder":89},[71,135,136],{"class":73,"line":93},[71,137,138],{},"errors: list[threading.ExceptHookArgs] = []\n",[71,140,141],{"class":73,"line":99},[71,142,90],{"emptyLinePlaceholder":89},[71,144,145],{"class":73,"line":105},[71,146,147],{},"def record(args: threading.ExceptHookArgs) -> None:\n",[71,149,150],{"class":73,"line":111},[71,151,152],{},"    errors.append(args)\n",[71,154,156],{"class":73,"line":155},8,[71,157,158],{},"    threading.__excepthook__(args)    # still print the traceback\n",[71,160,162],{"class":73,"line":161},9,[71,163,90],{"emptyLinePlaceholder":89},[71,165,167],{"class":73,"line":166},10,[71,168,169],{},"threading.excepthook = record\n",[62,171,173],{"className":64,"code":172,"language":66,"meta":67,"style":67},"# asyncio — structured concurrency propagates task exceptions.\nimport asyncio\n\nasync def main():\n    async with asyncio.TaskGroup() as tg:\n        tg.create_task(consume(queue))\n        tg.create_task(produce(queue))\n    # Any task's exception cancels the others and raises an ExceptionGroup here.\n",[14,174,175,180,185,189,194,199,204,209],{"__ignoreMap":67},[71,176,177],{"class":73,"line":74},[71,178,179],{},"# asyncio — structured concurrency propagates task exceptions.\n",[71,181,182],{"class":73,"line":80},[71,183,184],{},"import asyncio\n",[71,186,187],{"class":73,"line":86},[71,188,90],{"emptyLinePlaceholder":89},[71,190,191],{"class":73,"line":93},[71,192,193],{},"async def main():\n",[71,195,196],{"class":73,"line":99},[71,197,198],{},"    async with asyncio.TaskGroup() as tg:\n",[71,200,201],{"class":73,"line":105},[71,202,203],{},"        tg.create_task(consume(queue))\n",[71,205,206],{"class":73,"line":111},[71,207,208],{},"        tg.create_task(produce(queue))\n",[71,210,211],{"class":73,"line":155},[71,212,213],{},"    # Any task's exception cancels the others and raises an ExceptionGroup here.\n",[62,215,217],{"className":64,"code":216,"language":66,"meta":67,"style":67},"# Anything hung — dump every thread's stack on demand.\nimport faulthandler, signal\nfaulthandler.register(signal.SIGUSR1, all_threads=True)\n# kill -USR1 \u003Cpid>  → stacks of all threads on stderr\n",[14,218,219,224,229,234],{"__ignoreMap":67},[71,220,221],{"class":73,"line":74},[71,222,223],{},"# Anything hung — dump every thread's stack on demand.\n",[71,225,226],{"class":73,"line":80},[71,227,228],{},"import faulthandler, signal\n",[71,230,231],{"class":73,"line":86},[71,232,233],{},"faulthandler.register(signal.SIGUSR1, all_threads=True)\n",[71,235,236],{"class":73,"line":93},[71,237,238],{},"# kill -USR1 \u003Cpid>  → stacks of all threads on stderr\n",[240,241,244,345],"figure",{"className":242},[243],"diagram",[245,246,253,254,253,258,253,262,253,270,253,280,253,287,253,293,253,297,253,301,253,306,253,310,253,314,253,318,253,322,253,326,253,329,253,332,253,335,253,339,253,342],"svg",{"viewBox":247,"role":248,"ariaLabelledBy":249,"xmlns":252},"0 0 800 256","img",[250,251],"tt-t","tt-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[255,256,257],"title",{"id":250},"Where background exceptions go by default",[259,260,261],"desc",{"id":251},"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.",[263,264],"rect",{"x":265,"y":265,"width":266,"height":267,"rx":268,"fill":269},"0","800","256","14","#fffdf8",[271,272,279],"text",{"x":273,"y":274,"textAnchor":275,"fontSize":276,"fontWeight":277,"fill":278},"400","28","middle","15.5","700","#3d405b","Default destination, and the fix",[263,281],{"x":282,"y":283,"width":284,"height":285,"rx":286,"fill":278},"26","48","748","32","7",[271,288,292],{"x":289,"y":290,"fontSize":291,"fontWeight":277,"fill":269},"46","69","11.5","source",[271,294,296],{"x":295,"y":290,"fontSize":291,"fontWeight":277,"fill":269},"250","by default",[271,298,300],{"x":299,"y":290,"fontSize":291,"fontWeight":277,"fill":269},"530","make it surface",[263,302],{"x":282,"y":303,"width":284,"height":304,"rx":286,"fill":305},"86","44","#f4f1de",[271,307,16],{"x":289,"y":308,"fontSize":309,"fill":278},"113","11",[271,311,313],{"x":295,"y":308,"fontSize":309,"fill":312},"#8f3d22","printed to stderr, thread dies",[271,315,317],{"x":299,"y":308,"fontSize":309,"fill":316},"#2a5f49","recording excepthook",[263,319],{"x":282,"y":320,"width":284,"height":304,"rx":286,"fill":269,"stroke":321},"136","rgba(61,64,91,0.14)",[271,323,325],{"x":289,"y":324,"fontSize":309,"fill":278},"163","executor.submit",[271,327,328],{"x":295,"y":324,"fontSize":309,"fill":312},"stored on Future, maybe lost",[271,330,331],{"x":299,"y":324,"fontSize":309,"fill":316},"future.result()",[263,333],{"x":282,"y":334,"width":284,"height":304,"rx":286,"fill":305},"186",[271,336,338],{"x":289,"y":337,"fontSize":309,"fill":278},"213","asyncio.create_task",[271,340,341],{"x":295,"y":337,"fontSize":309,"fill":312},"reported at GC, if ever",[271,343,344],{"x":299,"y":337,"fontSize":309,"fill":316},"TaskGroup or await",[346,347,348],"figcaption",{},"None of the defaults propagate to the code that started the work; every fix is about bringing the exception back to it.",[34,350,352],{"id":351},"why-this-works","Why this works",[10,354,355,356,360,361,364],{},"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 ",[357,358,359],"em",{},"that"," stack and stops. Python hands it to ",[14,362,363],{},"threading.excepthook",", whose default prints the traceback and returns. The starting thread is on a different stack entirely and never sees it.",[10,366,367,368,371,372,375,376,379,380,48],{},"Futures bridge the gap by storing the exception object — traceback included — and re-raising it when ",[14,369,370],{},"result()"," is called on the other side. That re-raise is the propagation; skipping it is equivalent to catching and ignoring the exception. ",[14,373,374],{},"as_completed"," and ",[14,377,378],{},"wait"," are just ways of deciding the order in which to call ",[14,381,370],{},[10,383,384,385,388,389,392],{},"asyncio tasks work the same way: a task's exception is stored and re-raised when the task is awaited. ",[14,386,387],{},"TaskGroup"," awaits all its tasks on exit and, if any failed, cancels the rest and raises an ",[14,390,391],{},"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.",[34,394,396],{"id":395},"threads-in-pytest","Threads in pytest",[10,398,399,400,402,403,406],{},"pytest installs its own ",[14,401,363],{}," during each test. When a thread raises, pytest records the exception and emits ",[14,404,405],{},"PytestUnhandledThreadExceptionWarning"," with the traceback attached. A warning does not fail the test by default, so promote it:",[62,408,412],{"className":409,"code":410,"language":411,"meta":67,"style":67},"language-toml shiki shiki-themes github-light github-dark","# pyproject.toml\n[tool.pytest.ini_options]\nfilterwarnings = [\n    \"error::pytest.PytestUnhandledThreadExceptionWarning\",\n    \"error::pytest.PytestUnraisableExceptionWarning\",\n]\n","toml",[14,413,414,419,424,429,434,439],{"__ignoreMap":67},[71,415,416],{"class":73,"line":74},[71,417,418],{},"# pyproject.toml\n",[71,420,421],{"class":73,"line":80},[71,422,423],{},"[tool.pytest.ini_options]\n",[71,425,426],{"class":73,"line":86},[71,427,428],{},"filterwarnings = [\n",[71,430,431],{"class":73,"line":93},[71,432,433],{},"    \"error::pytest.PytestUnhandledThreadExceptionWarning\",\n",[71,435,436],{"class":73,"line":99},[71,437,438],{},"    \"error::pytest.PytestUnraisableExceptionWarning\",\n",[71,440,441],{"class":73,"line":105},[71,442,443],{},"]\n",[10,445,446,447,450],{},"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 ",[14,448,449],{},"__del__"," methods and garbage-collection callbacks, which is also where a never-retrieved asyncio task exception usually surfaces.",[240,452,454,561],{"className":453},[243],[245,455,253,460,253,463,253,466,253,483,253,486,253,489,253,497,253,502,253,508,253,513,253,518,253,524,253,528,253,532,253,537,253,541,253,544,253,550,253,554,253,558],{"viewBox":456,"role":248,"ariaLabelledBy":457,"xmlns":252},"0 0 800 226",[458,459],"ttp-t","ttp-d",[255,461,462],{"id":458},"pytest turning a thread crash into a failure",[259,464,465],{"id":459},"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.",[467,468,469,470,253],"defs",{},"\n    ",[471,472,478],"marker",{"id":473,"viewBox":474,"refX":475,"refY":476,"markerWidth":286,"markerHeight":286,"orient":477},"ttp-a","0 0 10 10","9","5","auto-start-reverse",[479,480],"path",{"d":481,"fill":482},"M0 0 L10 5 L0 10 z","#81b29a",[263,484],{"x":265,"y":265,"width":266,"height":485,"rx":268,"fill":269},"226",[271,487,488],{"x":273,"y":274,"textAnchor":275,"fontSize":276,"fontWeight":277,"fill":278},"From a line on stderr to a red test",[263,490],{"x":491,"y":492,"width":493,"height":494,"rx":495,"fill":305,"stroke":278,"strokeWidth":496},"20","80","140","56","10","1.5",[271,498,501],{"x":499,"y":500,"textAnchor":275,"fontSize":291,"fill":278},"90","112","worker raises",[263,503],{"x":504,"y":492,"width":505,"height":494,"rx":495,"fill":506,"stroke":482,"strokeWidth":507},"190","170","#e6f0ea","2",[271,509,512],{"x":510,"y":511,"textAnchor":275,"fontSize":291,"fontWeight":277,"fill":278},"275","104","pytest excepthook",[271,514,517],{"x":510,"y":515,"textAnchor":275,"fontSize":516,"fill":278},"124","10.5","records traceback",[263,519],{"x":520,"y":492,"width":521,"height":494,"rx":495,"fill":522,"stroke":523,"strokeWidth":507},"390","180","#f7f0da","#f2cc8f",[271,525,527],{"x":526,"y":511,"textAnchor":275,"fontSize":291,"fontWeight":277,"fill":278},"480","warning emitted",[271,529,531],{"x":526,"y":515,"textAnchor":275,"fontSize":516,"fill":530},"#8a5a00","Unhandled Thread Exception",[263,533],{"x":534,"y":492,"width":521,"height":494,"rx":495,"fill":535,"stroke":536,"strokeWidth":507},"600","#fbe9e3","#e07a5f",[271,538,540],{"x":539,"y":511,"textAnchor":275,"fontSize":291,"fontWeight":277,"fill":278},"690","test fails",[271,542,543],{"x":539,"y":515,"textAnchor":275,"fontSize":516,"fill":312},"filterwarnings = error",[73,545],{"x1":546,"y1":547,"x2":334,"y2":547,"stroke":482,"strokeWidth":548,"markerEnd":549},"162","108","1.8","url(#ttp-a)",[73,551],{"x1":552,"y1":547,"x2":553,"y2":547,"stroke":482,"strokeWidth":548,"markerEnd":549},"362","386",[73,555],{"x1":556,"y1":547,"x2":557,"y2":547,"stroke":482,"strokeWidth":548,"markerEnd":549},"572","596",[271,559,560],{"x":273,"y":334,"textAnchor":275,"fontSize":309,"fill":278},"Without the filter, the same crash leaves the test green.",[346,562,563],{},"Two lines of configuration turn every silent background crash in the suite into a failure with a traceback attached.",[34,565,567],{"id":566},"reading-a-traceback-that-crossed-a-thread-boundary","Reading a traceback that crossed a thread boundary",[10,569,570,571,573,574,576,577,580],{},"When ",[14,572,331],{}," 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 ",[14,575,370],{}," call; below that come frames from ",[14,578,579],{},"concurrent\u002Ffutures\u002F_base.py",", where the stored exception is re-raised; and below those, the worker's own frames, ending at the line that actually failed.",[10,582,583,584,587,588,591],{},"Only the last group is where the bug lives. The caller's frames explain ",[357,585,586],{},"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 ",[14,589,590],{},"^^^^"," markers under the failing expression appear only on the worker's final frame, which makes it easy to spot.",[10,593,594,597,598,601,602,605],{},[14,595,596],{},"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 ",[14,599,600],{},"_RemoteTraceback"," in the exception's ",[14,603,604],{},"__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.",[10,607,608,609,612,613,615,616,618],{},"asyncio tasks awaited normally are simpler, because the exception propagates through ",[14,610,611],{},"await"," on the same thread and the frames join naturally. With ",[14,614,387],{},", each failure appears as a sub-exception inside an ",[14,617,391],{},", drawn with box characters; each sub-exception carries its own complete traceback from its own task.",[240,620,622,687],{"className":621},[243],[245,623,253,628,253,631,253,634,253,637,253,640,253,644,253,649,253,653,253,657,253,661,253,665,253,669,253,673,253,676,253,680,253,683],{"viewBox":624,"role":248,"ariaLabelledBy":625,"xmlns":252},"0 0 800 246",[626,627],"ttb-t","ttb-d",[255,629,630],{"id":626},"Anatomy of a re-raised worker traceback",[259,632,633],{"id":627},"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.",[263,635],{"x":265,"y":265,"width":266,"height":636,"rx":268,"fill":269},"246",[271,638,639],{"x":273,"y":274,"textAnchor":275,"fontSize":276,"fontWeight":277,"fill":278},"Skip the plumbing, read the worker's frames",[263,641],{"x":642,"y":283,"width":643,"height":283,"rx":475,"fill":305,"stroke":278,"strokeWidth":496},"120","460",[271,645,648],{"x":646,"y":647,"textAnchor":275,"fontSize":291,"fontWeight":277,"fill":278},"350","70","caller frames",[271,650,652],{"x":646,"y":651,"textAnchor":275,"fontSize":516,"fill":278},"88","… data = fut.result()",[263,654],{"x":642,"y":511,"width":643,"height":655,"rx":475,"fill":522,"stroke":523,"strokeWidth":656},"40","1.6",[271,658,660],{"x":646,"y":659,"textAnchor":275,"fontSize":516,"fill":530},"129","concurrent\u002Ffutures\u002F_base.py — re-raise plumbing",[263,662],{"x":642,"y":663,"width":643,"height":664,"rx":475,"fill":535,"stroke":536,"strokeWidth":507},"152","60",[271,666,668],{"x":646,"y":667,"textAnchor":275,"fontSize":291,"fontWeight":277,"fill":278},"176","worker frames",[271,670,672],{"x":646,"y":671,"textAnchor":275,"fontSize":516,"fill":312},"196","fetch() → parse() → the failing line ^^^^",[271,674,586],{"x":534,"y":675,"fontSize":516,"fill":278},"76",[271,677,679],{"x":534,"y":678,"fontSize":516,"fill":278},"128","skip",[271,681,682],{"x":534,"y":334,"fontSize":516,"fontWeight":277,"fill":312},"the bug",[271,684,686],{"x":273,"y":685,"textAnchor":275,"fontSize":516,"fill":278},"232","Process pools: worker frames arrive as a _RemoteTraceback in __cause__.",[346,688,689],{},"The failing line is always at the bottom of the worker segment, marked by the fine-grained location carets.",[34,691,693],{"id":692},"a-fixture-that-fails-on-background-crashes","A fixture that fails on background crashes",[10,695,696,697,699],{},"The pytest warning filter covers ",[14,698,16],{},", 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:",[62,701,703],{"className":64,"code":702,"language":66,"meta":67,"style":67},"@pytest.fixture(autouse=True)\ndef no_lost_futures(monkeypatch):\n    seen = []\n    real_submit = ThreadPoolExecutor.submit\n    def submit(self, *a, **kw):\n        fut = real_submit(self, *a, **kw)\n        seen.append(fut)\n        return fut\n    monkeypatch.setattr(ThreadPoolExecutor, \"submit\", submit)\n    yield\n    for fut in seen:\n        if fut.done() and fut.exception() is not None:\n            raise fut.exception()\n",[14,704,705,710,715,720,725,730,735,740,745,750,755,761,767],{"__ignoreMap":67},[71,706,707],{"class":73,"line":74},[71,708,709],{},"@pytest.fixture(autouse=True)\n",[71,711,712],{"class":73,"line":80},[71,713,714],{},"def no_lost_futures(monkeypatch):\n",[71,716,717],{"class":73,"line":86},[71,718,719],{},"    seen = []\n",[71,721,722],{"class":73,"line":93},[71,723,724],{},"    real_submit = ThreadPoolExecutor.submit\n",[71,726,727],{"class":73,"line":99},[71,728,729],{},"    def submit(self, *a, **kw):\n",[71,731,732],{"class":73,"line":105},[71,733,734],{},"        fut = real_submit(self, *a, **kw)\n",[71,736,737],{"class":73,"line":111},[71,738,739],{},"        seen.append(fut)\n",[71,741,742],{"class":73,"line":155},[71,743,744],{},"        return fut\n",[71,746,747],{"class":73,"line":161},[71,748,749],{},"    monkeypatch.setattr(ThreadPoolExecutor, \"submit\", submit)\n",[71,751,752],{"class":73,"line":166},[71,753,754],{},"    yield\n",[71,756,758],{"class":73,"line":757},11,[71,759,760],{},"    for fut in seen:\n",[71,762,764],{"class":73,"line":763},12,[71,765,766],{},"        if fut.done() and fut.exception() is not None:\n",[71,768,770],{"class":73,"line":769},13,[71,771,772],{},"            raise fut.exception()\n",[10,774,775,776,778],{},"It is blunt, and it belongs in tests rather than production code, but it turns every forgotten ",[14,777,370],{}," into a failing test with the worker's traceback — which is usually enough to persuade the code's owner to collect results properly.",[34,780,782],{"id":781},"production-making-background-failures-visible","Production: making background failures visible",[10,784,785],{},"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.",[10,787,788,789,791,792,795,796,799],{},"Install a process-wide ",[14,790,363],{}," that logs through the application's logger with ",[14,793,794],{},"exc_info"," set, so thread crashes reach the same aggregation system as request errors instead of a stderr stream nobody reads. For asyncio, set ",[14,797,798],{},"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.",[10,801,802],{},"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.",[34,804,806],{"id":805},"edge-cases-and-failure-modes","Edge cases and failure modes",[39,808,809,816,826,842,850],{},[42,810,811,815],{},[812,813,814],"strong",{},"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.",[42,817,818,821,822,825],{},[812,819,820],{},"Daemon threads at shutdown."," Daemon threads are killed at interpreter exit without running their ",[14,823,824],{},"finally"," blocks; exceptions there are never reported. Avoid daemon threads for work whose failure matters.",[42,827,828,834,835,838,839,841],{},[812,829,830,833],{},[14,831,832],{},"executor.map"," hides later failures."," It re-raises the first exception when iteration reaches it and discards the rest. Use ",[14,836,837],{},"submit"," plus ",[14,840,374],{}," when every failure matters.",[42,843,844,849],{},[812,845,846,48],{},[14,847,848],{},"gather(return_exceptions=True)"," Exceptions come back as values in the result list; code that ignores the list ignores the errors.",[42,851,852,855,856,859,860,863,864,867],{},[812,853,854],{},"Hard hangs."," If a thread is deadlocked rather than crashed, there is no exception. Use ",[14,857,858],{},"faulthandler.dump_traceback_later(timeout)"," or ",[14,861,862],{},"pytest-timeout"," with ",[14,865,866],{},"timeout_method = thread"," to get stacks from every thread.",[34,869,871],{"id":870},"frequently-asked-questions","Frequently Asked Questions",[10,873,874,877,878,880,881,883,884,48],{},[812,875,876],{},"Why did an exception in my thread not fail the test?","\nAn exception in a ",[14,879,16],{}," ends that thread and is passed to ",[14,882,363],{},", 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 ",[14,885,405],{},[10,887,888,891,892,894,895,897,898,900],{},[812,889,890],{},"Where does an exception in a ThreadPoolExecutor task go?","\nIt is stored on the ",[14,893,24],{}," and re-raised when you call ",[14,896,331],{},". If nothing calls ",[14,899,370],{},", the exception is silently discarded when the Future is garbage collected.",[10,902,903,906,907,910,911,914],{},[812,904,905],{},"How do I see where every thread is stuck?","\nCall ",[14,908,909],{},"faulthandler.dump_traceback(all_threads=True)",", or register it on a signal with ",[14,912,913],{},"faulthandler.register(signal.SIGUSR1)",". It prints the current stack of every thread without needing the process to cooperate.",[34,916,918],{"id":917},"related","Related",[39,920,921,927,934,941],{},[42,922,923,926],{},[53,924,925],{"href":55},"Reading Tracebacks & Exception Chains"," — frame order and chains.",[42,928,929,933],{},[53,930,932],{"href":931},"\u002Fsystematic-debugging-performance-profiling\u002Freading-tracebacks-and-exception-chains\u002Fdecoding-during-handling-of-the-above-exception\u002F","Decoding \"During Handling of the Above Exception\""," — implicit and explicit chains.",[42,935,936,940],{},[53,937,939],{"href":938},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-async-code-and-event-loops\u002Fdiagnosing-task-was-destroyed-warnings\u002F","Diagnosing \"Task Was Destroyed\" Warnings"," — lost asyncio tasks.",[42,942,943,947],{},[53,944,946],{"href":945},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-async-code-and-event-loops\u002Ftracking-down-a-hung-await-with-task-stacks\u002F","Tracking Down a Hung await with Task Stacks"," — stacks for stuck coroutines.",[10,949,950,951],{},"← Back to ",[53,952,925],{"href":55},[954,955,956],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":67,"searchDepth":80,"depth":80,"links":958},[959,960,961,962,963,964,965,966,967,968],{"id":36,"depth":80,"text":37},{"id":59,"depth":80,"text":60},{"id":351,"depth":80,"text":352},{"id":395,"depth":80,"text":396},{"id":566,"depth":80,"text":567},{"id":692,"depth":80,"text":693},{"id":781,"depth":80,"text":782},{"id":805,"depth":80,"text":806},{"id":870,"depth":80,"text":871},{"id":917,"depth":80,"text":918},"Stop losing exceptions in background threads, executors and asyncio tasks: threading.excepthook, Future.result(), task exception handlers, faulthandler dumps and pytest's unhandled-thread warnings.","md",{"slug":972,"type":973,"breadcrumb":974,"datePublished":975,"dateModified":975,"faq":976,"howto":983},"getting-useful-tracebacks-from-threads-and-tasks","article","Thread & task tracebacks","2026-09-18",[977,979,981],{"q":876,"a":978},"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.",{"q":890,"a":980},"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.",{"q":905,"a":982},"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.",{"name":984,"description":985,"steps":986},"How to get tracebacks from background threads and tasks","Make background failures propagate or at least surface with full tracebacks, in production code and in pytest.",[987,990,993,996],{"name":988,"text":989},"Collect results from futures","Call result() on every Future, or use as_completed, so executor exceptions re-raise in the caller.",{"name":991,"text":992},"Install a threading excepthook","Record uncaught thread exceptions and fail the test or alert in production.",{"name":994,"text":995},"Handle asyncio task exceptions","Await tasks or use TaskGroup, and set a loop exception handler for fire-and-forget tasks.",{"name":997,"text":998},"Dump all stacks on demand","Register faulthandler on a signal to print every thread's stack when something hangs.","\u002Fsystematic-debugging-performance-profiling\u002Freading-tracebacks-and-exception-chains\u002Fgetting-useful-tracebacks-from-threads-and-tasks",{"title":5,"description":969},"systematic-debugging-performance-profiling\u002Freading-tracebacks-and-exception-chains\u002Fgetting-useful-tracebacks-from-threads-and-tasks\u002Findex","NWeAdJl-o2YUy9Z7KRbftUwqxFDS64B-F1Dqxejy-Xk",1789718769385]