pytest-dev/pytest · error · ExceptionGroup
multiple thread exception warnings
Error message
multiple thread exception warnings
What it means
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.
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.
Example fix
# before — two workers fail silently, pytest groups them
def test_x():
with ThreadPoolExecutor(max_workers=2) as ex:
ex.submit(fails_a); ex.submit(fails_b)
# after — assert explicitly, no group
def test_x():
with ThreadPoolExecutor(max_workers=2) as ex:
futs = [ex.submit(fails_a), ex.submit(fails_b)]
for f in futs:
with pytest.raises(ExpectedError):
f.result() Defensive patterns
Strategy: try-catch
Validate before calling
# Before relying on threads in a test, assert they do not raise.
from concurrent.futures import ThreadPoolExecutor
def run_and_check(workers):
with ThreadPoolExecutor(max_workers=len(workers)) as ex:
futs = [ex.submit(w) for w in workers]
# Surface failures inside the test body so pytest's thread hook stays empty.
errors = []
for f in futs:
try:
f.result()
except Exception as e:
errors.append(e)
assert not errors, errors Type guard
from concurrent.futures import Future
def all_done_ok(futures: list[Future]) -> bool:
"""True when every future completed without raising."""
return all(f.exception() is None for f in futures if f.done()) Try / catch
import pytest
from exceptiongroup import ExceptionGroup as _EG
try:
import ExceptionGroup # py3.11+
except ImportError:
pass
try:
run_test_with_threads()
except BaseException as e:
members = getattr(e, "exceptions", None)
if members and "thread exception" in str(e):
# Inspect each member.__cause__ for the real thread error.
for m in members:
log(m, m.__cause__)
raise Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- multiple unraisable exception warnings
- cannot create lockfile in
- is not a Warning subclass
- errors while tearing down fixture
- INTERNALERROR: Unknown entry type returned
AI-assisted analysis of pytest-dev/pytest@0d6fbdeffa (2026-08-11).
Data as JSON: /api/errors/62827611aec0a756.
Report an issue: GitHub.
Appendix: source
Thrown at src/_pytest/threadexception.py:72
continue
msg = meta.msg
try:
warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg))
except pytest.PytestUnhandledThreadExceptionWarning as e:
# This except happens when the warning is treated as an error (e.g. `-Werror`).
if meta.exc_value is not None:
# Exceptions have a better way to show the traceback, but
# warnings do not, so hide the traceback from the msg and
# set the cause so the traceback shows up in the right place.
e.args = (meta.cause_msg,)
e.__cause__ = meta.exc_value
errors.append(e)
if len(errors) == 1:
raise errors[0]
if errors:
raise ExceptionGroup("multiple thread exception warnings", errors)
finally:
del errors, meta, hook_error
def cleanup(
*, config: Config, prev_hook: Callable[[threading.ExceptHookArgs], object]
) -> None:
try:
try:
# We don't join threads here, so exceptions raised from any
# threads still running by the time _threading_atexits joins them
# do not get captured (see #13027).
collect_thread_exception(config)
finally:
threading.excepthook = prev_hook
finally:
del config.stash[thread_exceptions]
View on GitHub (pinned to 0d6fbdeffa)