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.0andpytest-asyncio >= 0.24, which is the first release carryingloop_scopeon both the marker and the fixture decorator.- Python 3.10+ for
asyncio.timeoutand the loop-free construction ofasyncio.Lock,EventandQueue. - Familiarity with pytest fixture scopes; the synchronous rules are covered in mastering pytest fixtures.
- If you are still on
pytest-asyncio < 1.0with a customevent_loopfixture, 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.
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
# 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
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
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
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
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:
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}")
$ 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
| Symptom | Root cause | Fix |
|---|---|---|
RuntimeError: Event loop is closed on the second test | Session-scoped resource built on a function loop | Add loop_scope="session" to the fixture |
got Future attached to a different loop | Two loops alive; a future created on one awaited on the other | Make every fixture in the chain share one loop_scope |
PytestUnhandledCoroutineWarning or an error about a non-None return | The test was never claimed by a plugin | Set asyncio_mode = "auto" or add the marker |
| Async test reported as skipped with no reason | anyio claimed it first and its backend fixture is missing | Isolate backend-parametrised tests in their own directory |
fixture 'event_loop' not found after an upgrade | The override was removed in 1.0 | Delete it; use loop_scope and event_loop_policy |
| Teardown raises but the test passed | await in a finally running after the loop closed | Match 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.
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.
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:
# 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()
# 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.
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.
# Where the time goes: setup, call, or teardown, per test.
pytest --durations=20 --durations-min=0.05 -q
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:
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.
Related guides
- Decide the collection mode deliberately with configuring asyncio_mode: auto versus strict.
- Share one loop across a module using sharing an event loop across a test module.
- Get teardown right for streaming resources in testing async generators and context managers.
- Compare the plugin with the backend-agnostic alternative in pytest-asyncio vs anyio scoping trade-offs.
- When a loop error survives the fixes here, move to debugging the event loop is closed RuntimeError.
← Back to Testing Async & Concurrent Python