You promote an async fixture from function scope to session scope to avoid reconnecting a client for every test, and the suite explodes with RuntimeError: ... attached to a different loop or Event loop is closed. The root cause is a scope mismatch between the fixture's lifetime and the event loop's lifetime — and the two leading frameworks, pytest-asyncio and anyio, resolve it with fundamentally different models. This is a decision page: it lays out how each scopes the loop, where each breaks, and which to pick.
Prerequisites
- Python 3.9+
pytest >= 7.0pytest-asyncio >= 0.23(theloop_scopeparameter and theasyncio_mode/loop-scope split were introduced in 0.23; earlier versions use the removedevent_loopfixture override pattern) oranyio >= 4.0withpytest >= 7- Background on async fixture lifecycles from How to Scope Pytest Fixtures for Async Tests.
Solution
The decision hinges on two axes: how many concurrency backends you must support, and how loop lifetime maps onto fixture scope.
pytest-asyncio with loop_scope
In pytest-asyncio >= 0.23, the event loop's lifetime is set by loop_scope, separately from a fixture's scope. To share a session-scoped async resource, both the fixture and the tests must declare the same loop scope so they run on one loop.
# conftest.py (requires pytest-asyncio >= 0.23)
import pytest
import pytest_asyncio
import asyncio
@pytest_asyncio.fixture(loop_scope="session", scope="session")
async def shared_client():
# Created and awaited on the SESSION loop, so it stays valid all session.
await asyncio.sleep(0) # stand-in for connect()
client = {"connected": True}
yield client
client["connected"] = False # torn down on the same loop
# test_asyncio_scope.py
import pytest
@pytest.mark.asyncio(loop_scope="session")
async def test_uses_shared_client(shared_client):
assert shared_client["connected"] is True
The crucial point: a session-scoped fixture with a function-scoped loop will fail, because the resource is created on a loop that closes after the first test. The loop_scope on both sides keeps the loop alive.
anyio with the backend fixture
anyio runs the same test on multiple backends and manages the loop through the anyio_backend fixture; you write backend-agnostic code and never touch the loop directly.
# test_anyio_scope.py (requires anyio >= 4.0)
import pytest
import anyio
@pytest.fixture
def anyio_backend():
return "asyncio" # or parametrize: ["asyncio", "trio"]
@pytest.fixture
async def shared_client(anyio_backend):
# anyio governs the loop; the fixture lives on the backend it provides.
await anyio.sleep(0)
client = {"connected": True}
yield client
client["connected"] = False
@pytest.mark.anyio
async def test_uses_shared_client(shared_client):
assert shared_client["connected"] is True
Decision matrix:
| Concern | pytest-asyncio (>= 0.23) | anyio (>= 4.0) |
|---|---|---|
| Backends | asyncio only | asyncio and trio |
| Loop control | Explicit via loop_scope | Implicit via anyio_backend |
| Fixture/loop scope coupling | You match them manually | Framework manages it |
| Structured concurrency | Use asyncio primitives directly | First-class task groups, cancel scopes |
| Best when | asyncio-only app, need per-test loop tuning | Library shipping to both backends, want portability |
Why this works
pytest-asyncio separates loop lifetime (loop_scope) from fixture lifetime (scope) so you can keep one loop alive exactly as long as the resources awaited on it, which is what eliminates cross-loop errors for session-scoped clients. anyio instead makes the loop an implementation detail of the anyio_backend fixture, trading that fine-grained control for backend portability and structured concurrency. Pick the model whose default matches your dominant constraint: explicit loop scoping for asyncio-only suites, backend abstraction for dual-backend libraries.
Edge cases and failure modes
- Pre-0.23
event_loopoverride. Old guides redefine theevent_loopfixture to widen scope; this is deprecated and removed in modern pytest-asyncio. Useloop_scopeinstead. - Mismatched scopes. A
scope="session"fixture markedloop_scope="function"recreates the resource per loop and fails on reuse — the two must agree. - "Event loop is closed" on teardown. A fixture awaiting cleanup after its loop closed; covered in depth under debugging async code and event loops.
- anyio trio incompatibility. Code using asyncio-only APIs (e.g.
asyncio.get_event_loop) breaks when theanyio_backendparametrizestrio; keep fixtures backend-neutral. - Hypothesis async tests. Combining
@givenwith async fixtures adds health-check concerns on top of loop scoping; see fixing Hypothesis FlakyHealthCheck failures.
Frequently Asked Questions
What does loop_scope do in pytest-asyncio?loop_scope, added in pytest-asyncio 0.23, controls the lifespan of the event loop independently of fixture scope. Setting loop_scope="session" on the asyncio mark and matching async fixtures keeps one loop alive across the session, so a session-scoped async resource is created and awaited on the same loop.
Why do I get "attached to a different loop" errors with session-scoped async fixtures?
Before pytest-asyncio 0.23 each test got a fresh event loop, so a session-scoped async fixture created on one loop was awaited on another. Set a matching loop_scope on both the fixture and the tests, or use anyio, whose anyio_backend fixture governs the loop uniformly.
When should I choose anyio over pytest-asyncio?
Choose anyio when your library must support both asyncio and trio, or when you want structured concurrency and a single backend-agnostic fixture model. Choose pytest-asyncio when you are asyncio-only and want fine-grained per-test loop scoping via loop_scope.
← Back to Mastering Pytest Fixtures