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 |
The two plugins put the loop under different ownership, which is the root of every behavioural difference below.
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; walk the fix under debugging the "Event loop is closed" RuntimeError.
- 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.
Migrating between the two without a rewrite
Teams usually arrive at this comparison mid-project, with a suite already written against one plugin. The migration is mechanical in one direction and requires a decision in the other.
Moving from pytest-asyncio to anyio is the easy direction. Replace the @pytest.mark.asyncio markers with a single anyio_backend fixture, drop every loop_scope argument, and convert session-scoped async fixtures into synchronous factories that the test awaits. That last step is the real work: anyio runs each test in its own runner, so a shared connection pool cannot be an async fixture at session scope. The usual answer is a synchronous session-scoped fixture that holds connection parameters, plus a function-scoped async fixture that opens and closes the connection.
import pytest
@pytest.fixture(scope="session")
def anyio_backend():
return "asyncio" # or ("asyncio", {"use_uvloop": True})
@pytest.fixture(scope="session")
def dsn(postgres_container): # sync: no loop involved, safe to share
return postgres_container.dsn
@pytest.fixture # function-scoped async: one runner, no mismatch
async def conn(dsn):
async with await connect(dsn) as connection:
yield connection
@pytest.mark.anyio
async def test_insert(conn):
assert await conn.fetchval("SELECT 1") == 1
The cost is a connection per test. On a local database that is a millisecond or two; against a container with TLS it can be ten, which multiplied across a thousand tests is the argument for staying with pytest-asyncio and a session-scoped loop.
Moving the other way — anyio to pytest-asyncio — is only worth doing when you need a shared loop for performance, and it obliges you to keep fixture scope and loop scope aligned everywhere. Do it in one commit per directory rather than file by file, because a partially migrated directory ends up with two loop scopes in the same session and the resulting teardown errors are hard to attribute.
Two rules hold whichever direction you go. Never mix the markers in one module: @pytest.mark.asyncio and @pytest.mark.anyio in the same file means two plugins both trying to run the coroutine, and the failure is an unhelpful "coroutine was never awaited". And pin the plugin version in the lockfile with a comment naming the loop-scope behaviour you depend on — pytest-asyncio changed its default fixture loop scope across minor versions, and a silent upgrade turns a working suite into a teardown-error suite with no code change to point at.
If you support both asyncio and trio in production, the decision is already made: only anyio can run the same test body against both backends, and parametrising anyio_backend gives you that for the cost of one fixture.
One last operational difference worth budgeting for: debuggability. A pytest-asyncio suite with a session loop keeps every task on one loop, so asyncio.all_tasks() in a breakpoint shows the whole picture, and a leaked task from test seven is visible in test eight. Under anyio, each test's runner is torn down with the test, so a leak cannot propagate — but it also cannot be observed after the fact. Choose the model that matches how your team debugs: shared-loop suites need discipline about task cleanup, isolated-runner suites need the failure to reproduce inside a single test.
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.
Can I run pytest-asyncio and anyio in the same repository?
Yes, provided no single module uses both markers and the two are separated by directory. Give each directory its own conftest so the plugin-specific fixtures do not leak sideways, and run them as separate CI jobs when the loop-scope settings differ — a shared ini file with one asyncio_default_fixture_loop_scope cannot describe both suites correctly.
Related
- How to scope pytest fixtures for async tests — the scope-mismatch fundamentals this decision builds on.
- Debugging the "Event loop is closed" RuntimeError — diagnosing the teardown failure a wrong loop scope produces.
- Tracing unawaited coroutine warnings — the other symptom class when async fixtures and loops are misconfigured.
- Mock vs MagicMock vs AsyncMock — when to use each — picking the right double once your async fixtures resolve on a stable loop.
← Back to Mastering Pytest Fixtures