pytest-dev/pytest · error · ExceptionGroup

multiple unraisable exception warnings

Error message

multiple unraisable exception warnings

What it means

pytest installs sys.unraisablehook to capture exceptions Python could not propagate — typically from __del__ methods, GC-time finalizers, or C-extension callbacks. Each captured event becomes a PytestUnraisableExceptionWarning; at each test-phase boundary and at session unconfigure, collect_unraisable() drains the queue. Two or more captured warnings promoted to errors (via -Werror or filterwarnings=error) are wrapped in an ExceptionGroup so none are silently dropped. One is re-raised directly.

Solutions

  1. Inspect each ExceptionGroup member — its __cause__ is the original unraisable exception; fix the __del__ or finalizer at the source.
  2. Replace __del__ with explicit context managers (with statements) or pytest fixture teardowns so failures become normal test errors, not unraisable ones.
  3. Add a targeted filterwarnings entry (e.g. `ignore::pytest.PytestUnraisableExceptionWarning`) if the unraisable events are expected noise.
  4. Force deterministic cleanup (gc.collect() inside the test, close sockets/files explicitly) so the unraisable never fires during pytest's hooks.

Example fix

# before — __del__ raises, pytest groups multiple events
class Conn:
    def __del__(self):
        self.sock.close()  # may raise if already closed

# after — explicit close, no __del__
class Conn:
    def close(self):
        try:
            self.sock.close()
        except OSError:
            pass

# in the test
with make_conn() as c:
    ...
c.close()
Defensive patterns

Strategy: try-catch

Validate before calling

import gc, sys, pytest

def assert_no_unraisable():
    """Call at the end of a test to flush and assert no unraisable events."""
    gc.collect()
    # pytest's hook stashes events; if the stash is empty, we are clean.
    # Equivalent: rely on filterwarnings to surface them as test failures.

Type guard

def has_pending_unraisable(deque) -> bool:
    return len(deque) > 0

Try / catch

import pytest

def test_something_careful():
    with pytest.MonkeyPatch().context() as mp:
        # If you expect unraisable noise, filter it; otherwise let it error.
        mp.setattr("warnings.filters", warnings.filters[:])
    try:
        do_work()
    except BaseException as e:
        members = getattr(e, "exceptions", None)
        if members and "unraisable" in str(e):
            for m in members:
                inspect(m.__cause__)
            raise

Prevention

When it happens

Trigger: Two or more objects raise inside __del__ during a single test phase; a C extension logs unraisable errors twice; coroutines/sockets/files whose finalizers each fail during GC; running with filterwarnings=error so the otherwise-warned unraisable events turn into errors and aggregate.

Common situations: Tests that construct objects with buggy __del__; asyncio loops not closed cleanly so multiple coroutine warnings fire; C-extension leaks surfacing as unraisable; PyPy where multi-pass GC resurrects objects and produces several events; CI with -Werror turning the warnings into a group.

Related errors


AI-assisted analysis of pytest-dev/pytest@0d6fbdeffa (2026-08-11). Data as JSON: /api/errors/448eb3aec771d3ab. Report an issue: GitHub.

Appendix: source

Thrown at src/_pytest/unraisableexception.py:81

                continue

            msg = meta.msg
            try:
                warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
            except pytest.PytestUnraisableExceptionWarning 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 unraisable exception warnings", errors)
    finally:
        del errors, meta, hook_error


def cleanup(
    *, config: Config, prev_hook: Callable[[sys.UnraisableHookArgs], object]
) -> None:
    # On PyPy, objects (e.g. coroutines) can survive GC rounds because executing
    # their __del__ can resurrect them. The Trio project determined experimentally
    # that 5 passes are needed on PyPy to flush everything. On CPython, reference
    # counting handles most cleanup immediately, so 1 pass is sufficient.
    _default_gc_collect_iterations = 5 if sys.implementation.name == "pypy" else 1
    gc_collect_iterations = config.stash.get(
        gc_collect_iterations_key, _default_gc_collect_iterations
    )
    try:
        try:
            gc_collect_harder(gc_collect_iterations)

View on GitHub (pinned to 0d6fbdeffa)