pytest-asyncio 'event loop is already running': there is no patch coming
It is a Tuesday evening and your FastAPI test suite has been green for months. Then somebody adds a parametrized test that picks its HTTP client at runtime, because there are now three of them: an anonymous client, an admin client, and one wired to a tenant fixture. The natural thing to write is request.getfixturevalue(client_name). It looks harmless. It is one line.
The suite explodes. Not one test, but every test that touches that pattern, and the traceback bottoms out somewhere inside pytest-asyncio's plugin with a message that has been confusing people since 2019.
Here is the part nobody tells you up front: this is not a bug in your code, and there is no version of pytest-asyncio you can upgrade to that fixes it. The upstream issue has been open for over seven years. The workaround everybody copies from Stack Overflow, nest_asyncio.apply(), is explicitly unsupported and has stopped working on current releases. You are not going to patch your way out. You are going to restructure your tests.
pytest's fixture protocol is synchronous, so pytest-asyncio has to drive an event loop to set up an async fixture. When you call request.getfixturevalue() from inside an already-running async test, it tries to drive a loop that is already running. Fixes, in order of preference:
- Replace dynamic lookup with indirect parametrization (
@pytest.mark.parametrize(..., indirect=True)), so pytest resolves the fixture before the test starts. - If you genuinely need runtime selection, make the fixture yield a factory you await inside the test, not the resolved object.
- As a blanket mitigation, put everything on one session-scoped loop:
asyncio_default_fixture_loop_scope = sessionandasyncio_default_test_loop_scope = session. - Do not reach for
nest_asyncio. It is unsupported and now fails with a different error.
The exact error: RuntimeError: This event loop is already running
On older Python and older pytest-asyncio, the traceback ends like this:
venv/lib/site-packages/_pytest/fixtures.py:586: in _compute_fixture_value
fixturedef.execute(request=subrequest)
...
pytest_asyncio/plugin.py:97: in wrapper
return loop.run_until_complete(setup())
/usr/lib/python3.11/asyncio/base_events.py:571: in run_until_complete
self.run_forever()
E RuntimeError: This event loop is already running
On Python 3.11 and newer, where pytest-asyncio uses asyncio.Runner, the same root cause surfaces with different wording:
E RuntimeError: Runner.run() cannot be called from a running event loop
Both mean the same thing. If you patched the first one with nest_asyncio and then upgraded, the second one is what you get next.
Why this happens
pytest was designed years before async def existed, and its fixture machinery reflects that. A fixture function is called synchronously, its return value is cached, and the value is handed to the test. There is no await anywhere in that protocol.
So when you write an async fixture, pytest-asyncio cannot simply await it. It wraps it in a synchronous shim that starts or borrows an event loop and blocks on the coroutine until it produces a value. That works fine in the normal case because of when it happens: pytest resolves every fixture named in the test signature during setup, before the async test function itself is scheduled on the loop. The loop is idle at that moment, so blocking on it is legal.
request.getfixturevalue() breaks that ordering. It says "resolve this fixture right now", and "right now" is the middle of your test, which is a coroutine that the event loop is currently executing. The synchronous shim tries to call run_until_complete (or Runner.run) on the loop that is at that instant executing the frame that called it. asyncio refuses. It has to: re-entering a running loop would mean the loop is driving two independent stacks at once, which its scheduler has no concept of.
This is why the same code works when the fixture is a plain function argument and fails when it is fetched dynamically. Nothing about the fixture changed. Only the moment it was resolved.
The fix, in numbered steps
Step 1: Confirm it is really this
Search the suite for dynamic resolution before you change anything:
grep -rn "getfixturevalue" tests/
If that returns nothing and you still see the error, your cause is different: usually something calling asyncio.run() or loop.run_until_complete() inside an async test, or a library that does it for you (some sync SDK wrappers do). Same mechanism, same fix philosophy.
Step 2: Replace dynamic lookup with indirect parametrization
This covers the majority of real cases, which are "run this test against several clients". The broken version:
@pytest.mark.parametrize("client_name", ["anon_client", "admin_client"])
async def test_orders(request, client_name):
client = request.getfixturevalue(client_name) # boom
resp = await client.get("/orders")
assert resp.status_code in (200, 401)
The version that works, because pytest resolves the fixture during setup:
import pytest, pytest_asyncio, httpx
from myapp.main import app
@pytest_asyncio.fixture
async def client(request):
# request.param comes from indirect parametrization
headers = {"Authorization": "Bearer admin"} if request.param == "admin" else {}
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport,
base_url="http://test",
headers=headers) as c:
yield c
@pytest.mark.parametrize("client", ["anon", "admin"], indirect=True)
async def test_orders(client):
resp = await client.get("/orders")
assert resp.status_code in (200, 401)
One fixture, parametrized by request.param, resolved before your coroutine starts. The error cannot occur because the loop is idle at setup time.
Step 3: When you truly need runtime selection, yield a factory
Sometimes the choice really does depend on something computed inside the test. Then do not resolve a fixture at all. Have the fixture hand you something you can await:
@pytest_asyncio.fixture
async def make_client():
created = []
async def _make(role: str) -> httpx.AsyncClient:
headers = {"Authorization": f"Bearer {role}"} if role != "anon" else {}
transport = httpx.ASGITransport(app=app)
c = httpx.AsyncClient(transport=transport,
base_url="http://test", headers=headers)
created.append(c)
return c
yield _make
for c in created:
await c.aclose()
async def test_escalation(make_client):
anon = await make_client("anon")
assert (await anon.get("/orders")).status_code == 401
admin = await make_client("admin")
assert (await admin.get("/orders")).status_code == 200
The setup work now happens on the test's own loop, inside an await, which is exactly where async work belongs. Teardown still runs through the fixture, so nothing leaks. This is the pattern the upstream discussion keeps circling back to, and it is the only one that genuinely restores dynamic behaviour.
Step 4: Put everything on one session-scoped loop
If you cannot rewrite every call site today, the maintainer-suggested mitigation is to stop having multiple loops at all. In pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
Or in pytest.ini:
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = session
asyncio_default_test_loop_scope = session
Both keys accept function, class, module, package or session. asyncio_default_fixture_loop_scope otherwise defaults to the fixture's own scope, and asyncio_default_test_loop_scope defaults to function, which is what gives you a fresh loop per test and all the cross-loop grief that comes with it.
Understand the trade you are making. A single session loop means shared state between tests: a connection pool opened by test 3 is still alive in test 300. That is a real isolation cost. It is also the only configuration in which long-lived async resources such as an asyncpg pool or a SQLAlchemy async engine behave sanely across a suite.
Step 5: Make your session-scoped fixtures actually session-scoped
Setting the loop scope is half of it. The resources have to match, or you trade one error for another:
# conftest.py
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def engine():
eng = create_async_engine(TEST_DSN, pool_pre_ping=True)
yield eng
await eng.dispose()
The loop_scope argument pins which loop the fixture's setup and teardown run on, independently of pytest's caching scope. A session-scoped engine created on a function-scoped loop is the classic way to end up with attached to a different loop a hundred tests later.
Verification
Run the suite and confirm the error string is gone rather than merely rarer:
pytest -q 2>&1 | grep -i "event loop"
Expected output: nothing. If This event loop is already running, Runner.run() cannot be called from a running event loop, Event loop is closed or attached to a different loop appears even once, you still have a loop-boundary problem somewhere.
Then prove ordering is not accidental by shuffling. If the suite only passes in file order, you have hidden cross-test state, which a session-scoped loop makes easier to create:
pip install pytest-randomly
pytest -q
Finally, check the plugin is doing what you think:
pytest --version --asyncio-mode=auto
The plugin list in the header should show pytest-asyncio, and no test should be reported as skipped with "async def functions are not natively supported", which is the signature of the plugin not being active at all.
What people get wrong
nest_asyncio.apply(). This is the top answer everywhere, and it is now actively harmful advice. It monkey-patches asyncio to permit re-entrant loops, which papers over the symptom while leaving your test suite running a patched event loop nobody upstream tests against. pytest-asyncio's maintainer has stated plainly that nest_asyncio compatibility is not supported. Users on recent versions report that after applying it they get Runner.run() cannot be called from a running event loop instead, because the code path moved to asyncio.Runner, which nest_asyncio does not patch. You end up with two problems.
Redefining the event_loop fixture. Half the blog posts on this subject tell you to override event_loop with a session-scoped version. That fixture was deprecated and then removed in pytest-asyncio 1.0.0. If you are on 1.x, that override does nothing at all, silently. Use the loop_scope argument and the two config keys above instead. Related: overriding event_loop_policy is itself deprecated as of 1.4.0 in favour of a loop factory hook, so do not build on that either.
Pinning to an old version to make it go away. Tempting, and I have seen teams stuck on a 2023 pin for exactly this reason. It does not fix issue #112, which reproduces on old and new releases alike, and it locks you out of the scope controls that actually help. You are paying a permanent tax for nothing.
Switching to a different async plugin. The constraint is pytest's fixture protocol being synchronous. Any plugin that offers async fixtures must bridge sync to async somewhere, and that bridge cannot be crossed from inside a running coroutine. Changing plugins changes the wording of the error, not the physics.
The related errors you will meet next
| Message | What it means | Move |
|---|---|---|
This event loop is already running | Sync bridge invoked from inside a running loop | Remove getfixturevalue on async fixtures; use indirect parametrization or a factory |
Runner.run() cannot be called from a running event loop | Same cause, Python 3.11+ asyncio.Runner path | Same fix; nest_asyncio will not help here |
Event loop is closed | Resource outlived the loop that created it | Match fixture scope and loop_scope; close pools in teardown |
attached to a different loop | Object built on loop A, awaited on loop B | Session loop scope, and build engines/pools inside a fixture with the same loop_scope |
When it is still broken
- Find the sync bridge. Run with
pytest --tb=long -xand read for a frame containingrun_until_complete,Runner.runorasyncio.run. Whatever calls it is your culprit, even if it lives in a third-party SDK rather than your tests. - Suspect sync client libraries. Some vendor SDKs offer an "async" surface that internally spins its own loop. Called from an async test, that is the same crime with a different uniform. Use the library's genuinely async client, or run it in
asyncio.to_thread. - Check for a stray conftest. A leftover
event_loopfixture in a nestedconftest.pyis dead weight on pytest-asyncio 1.x and live weight on 0.2x. Delete it either way. - Accept the architecture. If your test design fundamentally needs to resolve arbitrary fixtures at runtime, that design is fighting pytest itself. Reach for explicit factory fixtures, which is a better testing pattern regardless. Treat this the same way you would any other unfixable upstream constraint: design around it and move on, the way you would when a migration has to be safe to run twice because you cannot control how many times it fires.
The uncomfortable summary: this is a genuinely open upstream limitation, confirmed as reproducible on current releases by the maintainer, with a suggested mitigation rather than a fix. Build your suite so it does not depend on a patch that may never come. The factory-fixture pattern in Step 3 is not a workaround you will want to remove later. It is just the better way to write the test.
Frequently asked questions
- Why does request.getfixturevalue() break async fixtures in pytest?
- pytest's fixture protocol is synchronous, so pytest-asyncio wraps every async fixture in a shim that blocks on an event loop to produce the value. Normally that shim runs during setup, while the loop is idle. getfixturevalue() asks for fixture setup from inside your already-running test coroutine, so the shim tries to drive a loop that is currently executing, and asyncio raises RuntimeError: This event loop is already running.
- Does nest_asyncio still fix 'This event loop is already running' in pytest?
- No. pytest-asyncio does not support nest_asyncio, and on recent versions applying it just moves the failure to RuntimeError: Runner.run() cannot be called from a running event loop, because the plugin now goes through asyncio.Runner, which nest_asyncio does not patch. Restructure the test to resolve fixtures during setup, or use a factory fixture you await inside the test.
- How do I make all my pytest-asyncio tests share one event loop?
- Set asyncio_default_fixture_loop_scope = session and asyncio_default_test_loop_scope = session in your pytest config, and give long-lived fixtures a session loop_scope as well. Both keys accept function, class, module, package or session. The trade-off is reduced isolation, since state created by one test survives into the next.
- I overrode the event_loop fixture and nothing changed. Why?
- The event_loop fixture was deprecated and then removed in pytest-asyncio 1.0.0, so on any 1.x release your override is simply ignored. Use the loop_scope argument on pytest.mark.asyncio and pytest_asyncio.fixture, plus the asyncio_default_test_loop_scope and asyncio_default_fixture_loop_scope config keys, to control loop scoping instead.