Pytest & CI

pytest-asyncio vs anyio: Scoping Trade-offs

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.0
  • pytest-asyncio >= 0.23 (the loop_scope parameter and the asyncio_mode/loop-scope split were introduced in 0.23; earlier versions use the removed event_loop fixture override pattern) or anyio >= 4.0 with pytest >= 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.

Loop-scope ladder for async fixtures Comparison of how pytest-asyncio loop_scope and anyio map event-loop lifetime onto session, module, and function fixture scopes. Event-loop lifetime vs fixture scope pytest-asyncio (>= 0.23) anyio (>= 4.0) loop_scope='session' one loop for whole session loop_scope='module' loop per module loop_scope='function' fresh loop per test (default) anyio_backend fixture governs loop uniformly backend-agnostic asyncio or trio structured concurrency task groups, cancel scopes Rule: scope must not outlive its loop match loop_scope, or let anyio manage it
pytest-asyncio exposes loop lifetime directly via loop_scope; anyio hides it behind the anyio_backend fixture and adds backend portability and structured concurrency.

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.

Python
# 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.

Python
# 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:

Concernpytest-asyncio (>= 0.23)anyio (>= 4.0)
Backendsasyncio onlyasyncio and trio
Loop controlExplicit via loop_scopeImplicit via anyio_backend
Fixture/loop scope couplingYou match them manuallyFramework manages it
Structured concurrencyUse asyncio primitives directlyFirst-class task groups, cancel scopes
Best whenasyncio-only app, need per-test loop tuningLibrary 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_loop override. Old guides redefine the event_loop fixture to widen scope; this is deprecated and removed in modern pytest-asyncio. Use loop_scope instead.
  • Mismatched scopes. A scope="session" fixture marked loop_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 the anyio_backend parametrizes trio; keep fixtures backend-neutral.
  • Hypothesis async tests. Combining @given with 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