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's loop_scope and anyio map event-loop lifetime onto session, module, and function fixture scopes. pytest-asyncio exposes three explicit loop scopes; anyio governs the loop implicitly through the anyio_backend fixture. A shared rule at the bottom states that a fixture's scope must not outlive its loop. Event-loop lifetime vs fixture scope pytest-asyncio (≥ 0.23) loop lifetime set explicitly loop_scope="session" one loop for the whole session loop_scope="module" one loop per module loop_scope="function" fresh loop per test (default) anyio (≥ 4.0) loop lifetime managed for you anyio_backend fixture governs the loop uniformly backend-agnostic runs on asyncio or trio structured concurrency task groups, cancel scopes Rule: a fixture's scope must not outlive its loop match loop_scope on fixture and tests, or let anyio manage the loop
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

The two plugins put the loop under different ownership, which is the root of every behavioural difference below.

Who owns the loop in each plugin Two panels comparing pytest-asyncio and anyio-pytest: the loop lifetime model, how a fixture declares its scope, and what happens when a fixture outlives its loop in each. Who owns the loop in each plugin pytest-asyncio loop per declared scope loop_scope= on fixture and test asyncio only mismatch raises at teardown anyio runner per test, always anyio_mode / anyio_backend asyncio and trio no scope to mismatch Backend parametrisation is anyio’s other big lever: one test body, two runtimes.
pytest-asyncio gives you scope control and the obligation to keep two scopes in step; anyio removes the scope question by refusing to widen it.

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; 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 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.

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.

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

Per-test setup cost by fixture strategy A bar chart of approximate per-test connection setup cost: a session-scoped pool shared across tests, a function-scoped local connection, a function-scoped TLS connection to a container, and a fresh container per test. Per-test setup cost by fixture strategy session pool, shared loop ~0.2 ms per-test local connection ~1-2 ms per-test TLS to container ~8-10 ms container per test seconds Measure on your own infrastructure before choosing; TLS handshake dominates the middle rows.
The gap between rows two and three is the whole trade-off: anyio pays it per test, a shared loop pays it once.

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.

← Back to Mastering Pytest Fixtures