Async & Concurrency

pytest-asyncio in Depth

A suite passes locally and fails in CI with RuntimeError: Event loop is closed on the eleventh test, or with attached to a different loop, or it simply hangs until the job times out. In every case the cause is the same: an object was created on one event loop and used on another. pytest-asyncio is the layer that decides which loop is running when, so configuring it correctly is not a formality — it is the difference between a suite that scales past a hundred async tests and one that does not.

Prerequisites

  • pytest >= 8.0 and pytest-asyncio >= 0.24, which is the first release carrying loop_scope on both the marker and the fixture decorator.
  • Python 3.10+ for asyncio.timeout and the loop-free construction of asyncio.Lock, Event and Queue.
  • Familiarity with pytest fixture scopes; the synchronous rules are covered in mastering pytest fixtures.
  • If you are still on pytest-asyncio < 1.0 with a custom event_loop fixture, expect to delete it — the override was deprecated in 0.23 and removed in 1.0.

Core concept: two scopes, not one

The single idea that makes pytest-asyncio predictable is that a fixture has two independent lifetimes.

scope is the familiar pytest one: how often the fixture body executes. loop_scope is new and orthogonal: which event loop that body runs on. They default to the same value, which is why the distinction goes unnoticed until a suite needs a session-lived connection pool with per-test state.

Fixture scope and loop scope as independent axes A two-by-two grid. Function scope with a function loop gives full isolation at the highest setup cost. Function scope with a session loop gives per-test state on a shared loop, the usual choice for a connection acquired from a pool. Session scope with a session loop suits pools and servers. Session scope with a function loop is the broken combination that produces event loop is closed errors. scope decides how often; loop_scope decides where loop_scope → function loop session loop scope = function scope = session full isolation the safe default costs setup per test per-test state, shared loop a connection from a pool broken resource outlives its loop Event loop is closed pools and servers one handshake per run teardown still on the loop
Only one cell is wrong, and it is the one teams reach for first: widening scope to make a fixture faster without widening loop_scope to match.

The second idea is that asyncio_mode is about collection, not execution. In strict mode a coroutine test runs only when it carries @pytest.mark.asyncio; in auto mode every coroutine test function and async fixture is claimed automatically. auto is right for a suite that is async throughout, and actively wrong for a repository that also uses anyio, because both plugins implement pytest_pyfunc_call and only the first to claim a test wins.

Step-by-step implementation

1. Configure the plugin once, explicitly

TOML
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
# Explicit rather than implicit: without this, 0.24+ emits a deprecation warning
# and a future release changes the default under you.
asyncio_default_fixture_loop_scope = "function"

Setting asyncio_default_fixture_loop_scope is not optional housekeeping. Leaving it unset means the plugin picks a default that has already changed once, so a routine dependency bump can silently move every fixture onto a different loop. Pinning it makes the upgrade a deliberate change with a visible diff.

2. Put expensive resources on a wide loop

Python
import asyncpg
import pytest_asyncio


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def pool():
    # Both scopes agree: the pool's sockets are registered with the session loop
    # and closed while that loop is still alive.
    pool = await asyncpg.create_pool(dsn="postgresql://test@localhost/test",
                                     min_size=2, max_size=10)
    try:
        yield pool
    finally:
        await pool.close()

3. Keep per-test state narrow, on the same loop

Python
import pytest_asyncio


@pytest_asyncio.fixture(loop_scope="session")   # scope defaults to "function"
async def conn(pool):
    # New connection per test, session loop underneath: isolation without
    # re-handshaking, and nothing crosses a loop boundary.
    async with pool.acquire() as connection:
        transaction = connection.transaction()
        await transaction.start()
        try:
            yield connection
        finally:
            await transaction.rollback()

4. Mark the tests that need the wide loop

Python
import pytest

# Module-level: every test here runs on the session loop, so it may use `conn`.
pytestmark = pytest.mark.asyncio(loop_scope="session")


async def test_insert_is_rolled_back(conn):
    await conn.execute("INSERT INTO widget (name) VALUES ($1)", "test")
    assert await conn.fetchval("SELECT count(*) FROM widget") == 1

A test on a narrower loop than a fixture it requests is the error pytest-asyncio reports as a fixture being "requested from a different event loop". The rule is one-directional: a test may use fixtures on its own loop or on any wider one, never on a narrower one.

5. Swap the loop implementation through the policy

Python
import pytest
import uvloop


@pytest.fixture(scope="session")
def event_loop_policy():
    # Every loop created at session scope and below now comes from uvloop.
    return uvloop.EventLoopPolicy()

This is the supported replacement for the removed event_loop override. It changes how loops are made without touching when they live and die, which is why it composes with loop_scope instead of fighting it.

Verification

Do not infer loop identity — assert it. A temporary test that prints the running loop's id makes the whole configuration visible in one run:

Python
import asyncio

import pytest

pytestmark = pytest.mark.asyncio(loop_scope="session")


async def test_loop_identity_a(request):
    print(f"{request.node.name}: loop={id(asyncio.get_running_loop()):x}")


async def test_loop_identity_b(request):
    print(f"{request.node.name}: loop={id(asyncio.get_running_loop()):x}")
Plain text
$ pytest -s -q test_loops.py
test_loop_identity_a: loop=7f3c8a1b2e80
test_loop_identity_b: loop=7f3c8a1b2e80

Matching ids confirm the module shares a loop. Run pytest --setup-show alongside it to see the fixture order, and -W error::DeprecationWarning to surface plugin deprecations before they become breakage — the general technique is in turning warnings into errors with filterwarnings.

Troubleshooting

SymptomRoot causeFix
RuntimeError: Event loop is closed on the second testSession-scoped resource built on a function loopAdd loop_scope="session" to the fixture
got Future attached to a different loopTwo loops alive; a future created on one awaited on the otherMake every fixture in the chain share one loop_scope
PytestUnhandledCoroutineWarning or an error about a non-None returnThe test was never claimed by a pluginSet asyncio_mode = "auto" or add the marker
Async test reported as skipped with no reasonanyio claimed it first and its backend fixture is missingIsolate backend-parametrised tests in their own directory
fixture 'event_loop' not found after an upgradeThe override was removed in 1.0Delete it; use loop_scope and event_loop_policy
Teardown raises but the test passedawait in a finally running after the loop closedMatch the fixture's loop_scope to its scope

What actually breaks when loops are mismatched

It is worth understanding the failure at the level of the selector, because the error messages point at the symptom rather than the cause.

An asyncio loop owns a selector holding file descriptors registered by whatever ran on it. When the loop closes, those registrations go away, but the Python objects wrapping them do not: a connection object still has its socket, still believes it is usable, and still has a _loop attribute pointing at a closed loop. The next await on it schedules a callback on that dead loop, and you get Event loop is closed — from a line of code that is entirely correct, several tests away from the fixture that actually caused the problem.

Hangs come from the same mechanism one step further along. If the object's loop reference is to a loop that was never closed but is no longer running — common when a fixture created a loop manually — the await registers a callback that nothing will ever execute. No exception, no timeout, just a test that never returns. This is precisely why the timeout layer exists: a hung await with a deadline becomes a failing test with a stack trace, and a hung await without one becomes a cancelled CI job.

The diagnosis path is short once you know the shape. Run the failing test alone; if it passes, the problem is cross-test state. Then run it with the test immediately before it, which finds the pair. pytest --setup-show prints fixture setup and teardown in execution order with their scopes, and the offending fixture is the one whose scope is wider than the loop it ran on. Where the failure only appears under parallelism, the isolation techniques in debugging a test that only fails under xdist narrow it further.

How a scope mismatch becomes an error three tests later A timeline across three tests. During the first test a session-scoped fixture creates a pool on the function-scoped loop. That loop is closed at the end of the first test, but the pool object survives. The second test runs on a new loop, and the first await on the surviving pool raises Event loop is closed, far from the fixture that caused it. The fixture is at fault; the traceback names the victim test 1 loop A created pool built on loop A test passes teardown loop A closed pool object survives holds a dead selector test 2 loop B created await pool.fetch(...) RuntimeError: Event loop is closed Read the scope of the fixture, not the line number of the failure.
The error surfaces at the first await on the orphaned object, which is usually in a different test and a different module from the fixture that built it.

Fixtures that start a real server

The most common session-scoped async fixture is not a pool but a server: a FastAPI or aiohttp application bound to an ephemeral port so tests can exercise it over a real socket. It is also the fixture that most often gets the loop wrong, because the server keeps background tasks alive and those tasks hold references to the loop that created them.

Python
import asyncio
import socket

import pytest_asyncio
import uvicorn

from myapp.main import app


def _free_port() -> int:
    # Bind to port 0 and let the OS choose; hardcoding a port makes parallel
    # workers collide and produces "address already in use" in CI only.
    with socket.socket() as sock:
        sock.bind(("127.0.0.1", 0))
        return sock.getsockname()[1]


@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def live_server():
    port = _free_port()
    config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
    server = uvicorn.Server(config)
    task = asyncio.create_task(server.serve())          # runs on the session loop

    # Wait for readiness by polling the server's own flag, not by sleeping.
    for _ in range(200):
        if server.started:
            break
        await asyncio.sleep(0.01)
    else:
        raise RuntimeError("server did not start within 2 seconds")

    try:
        yield f"http://127.0.0.1:{port}"
    finally:
        server.should_exit = True
        await task                                      # teardown on the same loop

Three details make this fixture survive a large suite. The port is chosen by the operating system, so parallel workers never collide. Readiness is a poll on the server's own started flag with a bounded retry count, not a sleep — the same discipline as waiting for container readiness without sleep. And the shutdown awaits the serve task rather than cancelling it, so uvicorn's own cleanup runs and the port is released before the next module needs one.

The client that talks to this server should be function-scoped on the same loop. A session-scoped httpx.AsyncClient accumulates connection-pool state across tests, which is precisely the kind of hidden coupling that makes a suite order-dependent.

Migrating from the pre-1.0 plugin

Suites written against pytest-asyncio 0.21 and earlier carry two patterns that are now errors rather than warnings, and both appear in almost every older conftest.py.

The first is a custom event_loop fixture, usually widened to session scope to make a pool work:

Python
# BEFORE — removed in pytest-asyncio 1.0, raises at collection
import asyncio

import pytest


@pytest.fixture(scope="session")
def event_loop():
    loop = asyncio.new_event_loop()
    yield loop
    loop.close()
Python
# AFTER — express the same intent as configuration, not as a fixture override
# pyproject.toml:
#   [tool.pytest.ini_options]
#   asyncio_default_fixture_loop_scope = "session"
#
# and mark the tests that need it:
import pytest

pytestmark = pytest.mark.asyncio(loop_scope="session")

The replacement is better than the thing it replaces, because the old override applied to everything: one fixture in conftest.py silently moved the entire suite onto a session loop, including tests that wanted isolation. loop_scope is declared per test or per module, so widening is a local decision with a visible marker.

From an event_loop override to loop_scope Before migration, a session-scoped event_loop fixture in conftest silently moves every test in the suite onto one shared loop. After migration, asyncio_default_fixture_loop_scope sets fixture loops in configuration and a loop_scope marker widens only the modules that ask for it, leaving other tests isolated. Global override becomes a local, visible choice before · event_loop override one fixture in conftest.py every test on one session loop error in pytest-asyncio 1.0 after · loop_scope fixture loop scope in config marker on modules that need it other tests stay isolated
The same intent, expressed where a reviewer can see which tests share a loop.

The second pattern is the @pytest.mark.asyncio decorator on every test in a strict-mode suite. Switching to asyncio_mode = "auto" deletes those decorators wholesale, but only do it after confirming no other async plugin is installed — pip list | grep -E "anyio|trio|tornado" takes a second and prevents the silent double-collection described above.

A migration is worth doing in one change rather than incrementally. Mixed configuration, where half the suite relies on an event_loop override and half on loop_scope, produces loop lifetimes that depend on collection order, and collection order is exactly what changes when someone adds a file. Run the whole suite once with -W error afterwards; the deprecations that remain are the ones that will break at the next upgrade, and fixing them while the change is fresh costs far less than diagnosing them in six months.

What loop setup actually costs

Widening loop scope is usually justified on performance grounds, so it is worth knowing the size of the effect before restructuring a suite around it. Creating and closing an asyncio loop is cheap — on the order of a hundred microseconds — and on its own is never the reason a suite is slow. What is expensive is everything a fixture does inside that loop: TLS handshakes, connection pool warm-up, schema reflection, an application's startup event handlers.

That distinction determines where widening pays. A suite of 500 tests with a function-scoped loop and no async fixtures loses about 50 ms in total to loop churn, which is noise. The same suite where each test opens a Postgres connection loses 500 × 15 ms, or seven and a half seconds; where each test starts an application with its own startup hooks, the figure is minutes.

Bash
# Where the time goes: setup, call, or teardown, per test.
pytest --durations=20 --durations-min=0.05 -q
Plain text
12.41s setup    tests/api/test_orders.py::test_create_order
11.98s setup    tests/api/test_orders.py::test_cancel_order
 0.31s call     tests/api/test_orders.py::test_create_order

Setup dominating the call phase by two orders of magnitude is the signature that says "widen the fixture, and its loop with it". Call-phase dominance says the opposite: the loop is not the problem, and the fix is elsewhere — removing sleeps, faking a slow dependency, or the profiling workflow in CPU profiling with cProfile and py-spy.

One caveat applies to every widening decision. A session-scoped loop means a session-scoped failure domain: a test that leaves a task pending, a lock held, or a queue full hands that state to every test after it. The safeguard is cheap — an autouse function-scoped fixture that asserts the loop is clean when a test ends:

Python
import asyncio

import pytest


@pytest.fixture(autouse=True)
async def no_leaked_tasks():
    yield
    # Anything still pending after the test belongs to the test, not to the loop.
    pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
    assert not pending, f"test leaked {len(pending)} pending task(s): {pending}"

This turns "the suite is flaky after test 40" into "test 39 leaks a task", which is a one-line diagnosis instead of a bisection. Keeping it autouse costs microseconds per test and is worth it from the first shared loop onward; the trade-offs of autouse fixtures in general are covered in taming autouse fixtures in large suites.

Frequently Asked Questions

What is the difference between scope and loop_scope on an async fixture?scope decides how often the fixture body runs; loop_scope decides which event loop it runs on. A function-scoped fixture with loop_scope="session" executes once per test but always on the session loop, so objects it creates can safely be handed to other session-loop fixtures. Setting scope without loop_scope leaves the fixture on a function loop that dies after each test.

Why did the event_loop fixture stop working?pytest-asyncio deprecated overriding event_loop in 0.23 and removed it in 1.0. Redefining it now raises an error at collection. Replace a custom event_loop with the loop_scope argument for lifetime, and with an event_loop_policy fixture when you need a different loop implementation such as uvloop.

Does asyncio_mode=auto affect tests written for other async plugins? Yes, and that is the main hazard. In auto mode pytest-asyncio claims every coroutine test function it can see, including ones intended for anyio or trio. Keep backend-parametrised tests in a directory whose conftest.py sets asyncio_mode=strict, or run them as a separate invocation.

How do I run tests on uvloop instead of the default loop? Define an event_loop_policy fixture returning uvloop.EventLoopPolicy() at the scope you want it applied. pytest-asyncio creates its loops through the policy, so every loop at that scope and below uses uvloop without any test changing.

Can a synchronous fixture be used by an async test? Yes, and it should be whenever the fixture does no awaiting. Synchronous fixtures have no loop affinity at all, which makes them immune to every scoping problem in this guide. Only make a fixture async when it genuinely needs to await something.

← Back to Testing Async & Concurrent Python