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

  1. Inspect each member of the ExceptionGroup — each carries __cause__ pointing at the original thread exception; fix the root cause in the threads.
  2. If you intentionally want to ignore thread exceptions, add a filterwarnings entry (e.g. `ignore::pytest.PytestUnhandledThreadExceptionWarning`) instead of letting them accumulate.
  3. Ensure threads are joined and their exceptions asserted explicitly inside the test body, so pytest's excepthook has nothing to capture.
  4. 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

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


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)