One event loop per test
September 1, 2026 · 2 min read
RuntimeError: Task <Task pending ...> got Future <Future pending> attached to a different loop
If you have session-scoped async fixtures in a pytest-asyncio suite, you have probably seen this. The setup that produces it looks completely reasonable:
@pytest_asyncio.fixture(scope="session")
async def db():
pool = await asyncpg.create_pool(DSN)
yield pool
await pool.close()
What's actually happening: pytest-asyncio runs each test in its own event loop by default. The fixture is session-scoped, so pytest creates the pool once, on the loop of whichever test requests it first. That loop dies with the first test. The second test gets the cached pool, whose connections still point at the dead loop, and asyncpg blows up. Depending on the library you get the error above or just Event loop is closed.
A lot of things bind to a loop like this: asyncpg pools, aiohttp's ClientSession (the connector, really), motor clients. Also asyncio.Lock, Queue and Event — since 3.10 they attach to whatever loop first awaits them and raise if they meet another one.
The tempting fix is making the fixture function-scoped. It works, and it makes the suite slow: every test now opens a pool, does TLS handshakes, whatever else setup does. The annoying part is that this slowness never shows up as a bottleneck anywhere, it's a flat tax on every test.
What I do instead is widen the loop. Rule of thumb: a loop-bound fixture needs the fixture and every test touching it on the same loop scope, and that scope has to be at least as wide as the fixture's caching scope.
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
(pytest-asyncio prints a warning when that option is unset, which I suspect is how most people find out it exists.)
That covers fixtures. Tests still get function-scoped loops unless marked, so tests sharing the pool need @pytest.mark.asyncio(loop_scope="session"), or you apply it to the whole suite from conftest.py:
def pytest_collection_modifyitems(items):
marker = pytest.mark.asyncio(loop_scope="session")
for item in items:
if pytest_asyncio.is_async_test(item):
item.add_marker(marker, append=False)
This trades isolation for speed, so keep the shared loop-bound set small. A connection pool is fine to share; application state isn't. A test that messes with loop-global stuff (signal handlers, event loop policies) should keep its own loop via an explicit loop_scope="function".
Two footnotes. If an old Stack Overflow answer tells you to override the event_loop fixture with a wider scope, that's the pre-0.23 API and it now just prints deprecation warnings, don't bother. And RuntimeWarning: coroutine 'x' was never awaited in test output belongs to the same family of problems: some test called an async function without awaiting it and asserted nothing. -W error::RuntimeWarning turns those into failures, which they are.
The pytest-asyncio docs have a how-to specifically about running all tests in a single loop; worth reading before inventing your own scheme.