{"record":{"id":"62827611aec0a756","repo":"pytest-dev/pytest","slug":"multiple-thread-exception-warnings","errorCode":null,"errorMessage":"multiple thread exception warnings","messagePattern":"multiple thread exception warnings","errorType":"exception","errorClass":"ExceptionGroup","httpStatus":null,"severity":"error","filePath":"src/_pytest/threadexception.py","lineNumber":72,"sourceCode":"                continue\n\n            msg = meta.msg\n            try:\n                warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg))\n            except pytest.PytestUnhandledThreadExceptionWarning as e:\n                # This except happens when the warning is treated as an error (e.g. `-Werror`).\n                if meta.exc_value is not None:\n                    # Exceptions have a better way to show the traceback, but\n                    # warnings do not, so hide the traceback from the msg and\n                    # set the cause so the traceback shows up in the right place.\n                    e.args = (meta.cause_msg,)\n                    e.__cause__ = meta.exc_value\n                errors.append(e)\n\n        if len(errors) == 1:\n            raise errors[0]\n        if errors:\n            raise ExceptionGroup(\"multiple thread exception warnings\", errors)\n    finally:\n        del errors, meta, hook_error\n\n\ndef cleanup(\n    *, config: Config, prev_hook: Callable[[threading.ExceptHookArgs], object]\n) -> None:\n    try:\n        try:\n            # We don't join threads here, so exceptions raised from any\n            # threads still running by the time _threading_atexits joins them\n            # do not get captured (see #13027).\n            collect_thread_exception(config)\n        finally:\n            threading.excepthook = prev_hook\n    finally:\n        del config.stash[thread_exceptions]\n","sourceCodeStart":54,"sourceCodeEnd":90,"githubUrl":"https://github.com/pytest-dev/pytest/blob/0d6fbdeffa57c796123f62f81f7dd370d9b7ecdc/src/_pytest/threadexception.py#L54-L90","documentation":"pytest installs a threading.excepthook that stashes every exception raised in non-main threads during a test phase. At each phase boundary (setup/call/teardown) collect_thread_exception() drains the stash; if exactly one captured warning becomes an error it is re-raised directly, but two or more are wrapped in an ExceptionGroup so none are lost. The group is what surfaces when multiple threads fail (or when -W error promotes the PytestUnhandledThreadExceptionWarning to an error) in the same phase.","triggerScenarios":"A test spawns 2+ threads (ThreadPoolExecutor, threading.Thread, concurrent.futures, asyncio run_in_executor) and two of them raise during setup, call, or teardown of the same test. With -Werror, even pure warnings from each thread become errors and aggregate into the group.","commonSituations":"Concurrency tests where several workers hit the same bug; flaky thread pools under load; CI running with filterwarnings=error; a thread leak from a previous test surfacing during the next one's phase.","solutions":["Inspect each member of the ExceptionGroup — each carries __cause__ pointing at the original thread exception; fix the root cause in the threads.","If you intentionally want to ignore thread exceptions, add a filterwarnings entry (e.g. `ignore::pytest.PytestUnhandledThreadExceptionWarning`) instead of letting them accumulate.","Ensure threads are joined and their exceptions asserted explicitly inside the test body, so pytest's excepthook has nothing to capture.","Disable the plugin with `-p no:threadexception` only as a last-resort diagnostic."],"exampleFix":"# before — two workers fail silently, pytest groups them\ndef test_x():\n    with ThreadPoolExecutor(max_workers=2) as ex:\n        ex.submit(fails_a); ex.submit(fails_b)\n\n# after — assert explicitly, no group\ndef test_x():\n    with ThreadPoolExecutor(max_workers=2) as ex:\n        futs = [ex.submit(fails_a), ex.submit(fails_b)]\n    for f in futs:\n        with pytest.raises(ExpectedError):\n            f.result()","handlingStrategy":"try-catch","validationCode":"# Before relying on threads in a test, assert they do not raise.\nfrom concurrent.futures import ThreadPoolExecutor\n\ndef run_and_check(workers):\n    with ThreadPoolExecutor(max_workers=len(workers)) as ex:\n        futs = [ex.submit(w) for w in workers]\n    # Surface failures inside the test body so pytest's thread hook stays empty.\n    errors = []\n    for f in futs:\n        try:\n            f.result()\n        except Exception as e:\n            errors.append(e)\n    assert not errors, errors","typeGuard":"from concurrent.futures import Future\n\ndef all_done_ok(futures: list[Future]) -> bool:\n    \"\"\"True when every future completed without raising.\"\"\"\n    return all(f.exception() is None for f in futures if f.done())","tryCatchPattern":"import pytest\nfrom exceptiongroup import ExceptionGroup as _EG\ntry:\n    import ExceptionGroup  # py3.11+\nexcept ImportError:\n    pass\n\ntry:\n    run_test_with_threads()\nexcept BaseException as e:\n    members = getattr(e, \"exceptions\", None)\n    if members and \"thread exception\" in str(e):\n        # Inspect each member.__cause__ for the real thread error.\n        for m in members:\n            log(m, m.__cause__)\n        raise","preventionTips":["Always call future.result() inside the test so thread errors become normal assertion failures.","Join all threads before the test returns; lingering threads leak into the next test's phase.","If thread exceptions are expected noise, add a targeted filterwarnings ignore rule rather than letting them aggregate."],"tags":["threading","exceptiongroup","warnings","concurrency","xdist"],"backgroundTag":null,"analyzedSha":"0d6fbdeffa57c796123f62f81f7dd370d9b7ecdc","analyzedAt":"2026-08-11T20:52:36.969Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}