A module- or session-scoped async fixture that closes a connection pool during teardown frequently fails with RuntimeError: Event loop is closed, and a wider fixture that requests a narrower one raises ScopeMismatch during collection. Both symptoms have the same root cause: in pytest-asyncio, a fixture's pytest scope and the event loop it runs on are configured separately, and when they diverge the fixture's post-yield cleanup executes on a loop that no longer exists. This guide shows how to bind the two together with loop_scope so async setup and teardown always run on the same live loop.
Prerequisites
pytest >= 8.0andpytest-asyncio >= 0.23(theloop_scopeparameter on@pytest_asyncio.fixtureand@pytest.mark.asynciowas added in 0.23; earlier releases have only the globalevent_loopfixture override).- Python
3.9+(asyncio.get_running_loop(),asyncio.all_tasks()). asyncio_mode = "auto"and a default loop scope set inpyproject.toml:
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function" # explicit default; silences the 0.23 deprecation warning
This guide builds on the scope rules covered in Mastering Pytest Fixtures; the loop lifecycle itself is dissected in Debugging Async Code and Event Loops.
Solution
Declare the same value for scope and loop_scope, and run teardown inside a try/finally block that acquires the live loop with asyncio.get_running_loop():
import asyncio
import pytest_asyncio
from typing import AsyncGenerator
from myapp.db import AsyncConnectionPool
@pytest_asyncio.fixture(scope="module", loop_scope="module")
async def db_pool() -> AsyncGenerator[AsyncConnectionPool, None]:
"""Module-scoped pool whose loop survives for the whole module."""
pool = AsyncConnectionPool(dsn="postgresql+asyncpg://test:test@localhost/testdb")
await pool.connect() # setup runs on the module loop
try:
yield pool # tests share this single pool
finally:
# get_running_loop() is the loop pytest-asyncio is still driving;
# get_event_loop() may hand back a closed/foreign loop here.
loop = asyncio.get_running_loop()
# Cancel any tasks the pool spawned before closing it, so close()
# is not interrupted by a still-pending background coroutine.
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
for task in pending:
task.cancel()
# return_exceptions=True swallows the CancelledError each task raises.
await asyncio.gather(*pending, return_exceptions=True)
await pool.close() # teardown runs on the SAME loop
The mapping you almost always want:
| Fixture scope | loop_scope | Use for |
|---|---|---|
function | function | isolated unit tests, ephemeral transactions, HTTP mocks |
module | module | a local test server or pool shared by one file |
session | session | Dockerised databases, Kafka brokers, expensive pools |
The loop scope ladder below shows why a mismatch breaks teardown.
Why this works
pytest-asyncio creates one event loop per loop_scope boundary and runs every coroutine inside that scope on it, including the generator resume that executes your finally block. When loop_scope equals the fixture's scope, the loop is guaranteed to still be running when teardown fires, so await pool.close() succeeds. When loop_scope is left at its function default while the fixture is module or session, the loop is torn down after the first test and the deferred cleanup awaits on a dead loop — exactly the RuntimeError: Event loop is closed engineers see in CI but rarely locally, where a single test masks the boundary.
Edge cases and failure modes
- Cross-scope dependency raises
ScopeMismatch. Asession-scoped fixture cannot request afunction-scoped one: the narrow resource is destroyed first, and the two attach to different loops. Elevate the dependency's scope, pass the value via@pytest.mark.parametrize, or return a factory the wider fixture calls during the test rather than injecting directly. asyncio.get_event_loop()in teardown. It may return a closed loop or one from another thread. Always useasyncio.get_running_loop()inside async fixtures.- Unmatched
conftest.pyinheritance. An async fixture inherited from a parentconftest.pykeeps its declaredloop_scope, but if a child file omits it the loop is recreated per test. Pinloop_scopeexplicitly on shared async fixtures — see Managing Conftest Hierarchies for inheritance rules. - Background tasks outliving the fixture. WebSocket servers or queue consumers must be cancelled before the primary resource closes, then awaited with
asyncio.wait_for(task, timeout=...)andreturn_exceptions=Trueso a deadlocked task does not hang the suite. uvloop/ProactorEventLooppolicies. Custom loop policies change cancellation timing. Set the policy inpytest_configureand never rely on implicit GC to clean up tasks; cancel them explicitly.
Frequently Asked Questions
Can I use session-scoped fixtures with pytest-asyncio?
Yes. In pytest-asyncio 0.23 or newer, declare both scope="session" and loop_scope="session" so the fixture and its event loop share one lifetime. Without a matching loop_scope the loop is recreated per test and the session fixture loses its loop context after the first test.
Why does my async fixture raise RuntimeError: Event loop is closed during teardown?
The fixture's scope outlives the loop its post-yield cleanup runs in. The default loop_scope is function, so a module or session fixture tries to await teardown on a loop pytest-asyncio already closed. Set loop_scope to match the fixture scope so setup and teardown share one loop.
Why does an async fixture raise ScopeMismatch when it depends on another fixture?
A wider-scoped fixture cannot request a narrower-scoped one, because the narrow resource is torn down first. A session fixture depending on a function fixture raises ScopeMismatch. Elevate the dependency's scope, pass the value via parametrize, or use a factory instead of direct injection.
← Back to Mastering Pytest Fixtures