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.
The mismatch is easiest to see as two lifetimes drawn against each other.
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.
Auditing scopes before they bite
Scope mismatches are cheap to prevent and expensive to debug, because the failure surfaces in the second test rather than in the one that is wrong. Two commands and one rule catch nearly all of them.
The first command is pytest --setup-show, which prints every fixture setup and teardown with its scope as the session runs. Read it once for a file that uses async fixtures and the ordering problem becomes obvious: a SETUP S db line followed by repeated SETUP F event_loop lines means the connection is older than the loop it will be used on.
$ pytest --setup-show tests/test_orders.py -q
SETUP S db_pool
SETUP F event_loop
tests/test_orders.py::test_create (fixtures used: db_pool, event_loop)
TEARDOWN F event_loop # loop dies here...
SETUP F event_loop # ...a different loop for the next test
tests/test_orders.py::test_cancel (fixtures used: db_pool, event_loop)
TEARDOWN S db_pool # torn down on a loop it never saw
The second is pytest --fixtures -v, which lists every visible fixture with its scope and defining file. Scanning it for async fixtures whose scope is wider than function gives you the audit list in one pass; anything on that list must have a matching loop scope declared next to it.
The rule is simpler than either command: an async fixture may not be wider in scope than the loop it awaits on. With pytest-asyncio 0.23+ that means declaring both explicitly, and keeping them in the same place so a later edit cannot separate them:
import pytest
import pytest_asyncio
@pytest.fixture(scope="session")
def event_loop_policy():
# Policy is safe to share; the loop itself is created per scope by the plugin.
import asyncio
return asyncio.DefaultEventLoopPolicy()
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def db_pool():
pool = await create_pool(dsn="postgresql:///test")
yield pool # lives exactly as long as the session loop
await pool.close() # awaited on the same loop that created it
@pytest.mark.asyncio(loop_scope="session")
async def test_create(db_pool):
assert await db_pool.fetchval("SELECT 1") == 1
Two details make this stick in a real suite. Put the loop_scope argument on the fixture and on every test that consumes it — a test that omits it runs on a function-scoped loop and hits the same mismatch from the other direction. And set asyncio_default_fixture_loop_scope in the ini file so new fixtures inherit the intended default instead of the plugin's, which removes the most common way this regresses six months later.
When a suite mixes both, run the async tests in their own session with a distinct marker. Two loop scopes in one process is legal but hard to reason about, and the cost of a second pytest invocation is far lower than the cost of an intermittent teardown error nobody can reproduce. The audit is easier to keep up if the intended pairing is written down once, in the ini file, rather than repeated on every fixture:
[tool.pytest.ini_options]
asyncio_mode = "strict" # markers are required, never implicit
asyncio_default_fixture_loop_scope = "function" # new fixtures inherit this
With strict mode, a coroutine test without a marker is reported as an error rather than silently skipped, which removes the other half of the async-fixture confusion: a test that never ran but reported as passed. Setting the default fixture loop scope explicitly means a future fixture added without a loop_scope argument matches the plugin's documented behaviour rather than whatever the installed minor version happens to default to. Both settings are one line and both fail loudly, which is the property you want from configuration that guards an intermittent bug.
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.
Related
- pytest-asyncio vs anyio: scoping trade-offs — the decision layer above this one: when
loop_scopebeats anyio's backend-agnostic loop management, and vice versa. - Debugging async code and event loops — trace which loop a coroutine is bound to and diagnose "attached to a different loop" beyond the fixture layer.
- Managing conftest hierarchies — how an inherited async fixture keeps or loses its
loop_scopeacross nestedconftest.pyfiles. - Advanced parametrization techniques — pass a value into a wider-scoped fixture to sidestep the
ScopeMismatcha direct dependency would raise.
← Back to Mastering Pytest Fixtures