Choosing Event Loop Scopes in pytest-asyncio¶
pytest-asyncio runs each async test on an event loop, and since version 0.24 you choose how long that loop lives — per test, per class, per module, per package or per session — separately for tests (loop_scope) and for fixtures (scope plus loop_scope). The choice decides whether expensive async resources can be shared and how isolated tests are. Tested with pytest-asyncio 1.4 and a real asyncpg pool: 50 tests that each created their own pool took 1.62 s; the same 50 tests sharing one module-scoped pool on a module-scoped loop took 0.15 s. Getting the scopes wrong fails in two distinct ways: a module-scoped fixture left on the default function-scoped loop failed with ScopeMismatch, and a module-loop fixture used from a test running on its own function loop failed with got Future … attached to a different loop. This guide picks scopes deliberately and wires them so they agree.
Prerequisites¶
- Python 3.11+,
pip install pytest pytest-asyncio(tested with 1.4.0). - pytest-asyncio basics, from testing asyncio code with pytest-asyncio.
- Why loop-bound objects cannot move, from why connection pools are per process.
1. Start with the default: a fresh loop per test¶
By default every test, and every async fixture it uses, runs on a loop created for that test and closed afterwards:
# pytest.ini
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
@pytest_asyncio.fixture
async def pool():
p = await asyncpg.create_pool(DSN, min_size=2, max_size=2)
yield p
await p.close()
async def test_query(pool):
assert await pool.fetchval("select 1") == 1
This is the most isolated setup — no state, task or connection survives a test — and it is the right default. Its cost is whatever the fixtures cost per test: measured, creating and closing an asyncpg pool for each of 50 tests took 1.62 s in total. Setting asyncio_default_fixture_loop_scope explicitly silences pytest-asyncio's configuration warning and documents the choice.
Verify: the suite passes with the default scopes before you widen any of them.
2. Share expensive resources with a wider loop scope¶
To share a pool, server or client across tests, both the fixture and the tests that use it must run on the same, longer-lived loop:
@pytest_asyncio.fixture(scope="module", loop_scope="module")
async def pool():
p = await asyncpg.create_pool(DSN, min_size=2, max_size=2)
yield p
await p.close()
pytestmark = pytest.mark.asyncio(loop_scope="module") # every test in this module
async def test_query(pool):
assert await pool.fetchval("select 1") == 1
Measured: 0.15 s for 50 tests instead of 1.62 s. The fixture's scope controls how long the object lives; its loop_scope controls which loop it is created on; the test's loop_scope controls which loop the test runs on. All three must agree for loop-bound objects such as pools, clients and servers. Put tests that share a fixture in one module (or class), with the pytestmark at the top, so the requirement is visible.
Verify: the shared fixture is created once per module (log it), and tests in the module pass in any order.
3. Recognise the two mismatch errors¶
Each half of the configuration can be wrong on its own, and each produces its own error:
# 1. Fixture scope wider than its loop scope
@pytest_asyncio.fixture(scope="module") # loop_scope defaults to "function"
async def pool(): ...
# tested: ScopeMismatch: You tried to access the function scoped fixture
# _function_scoped_runner with a module scoped request object
# 2. Fixture on a module loop, test on its own function loop
@pytest_asyncio.fixture(scope="module", loop_scope="module")
async def pool(): ...
async def test_query(pool): # no loop_scope marker: runs on a new loop
await pool.fetchval("select 1")
# tested: RuntimeError: Task ... got Future ... attached to a different loop
# (followed by asyncpg InterfaceError: another operation is in progress)
Both were reproduced. The first is caught at setup and names the problem. The second fails inside the library on first use, and its follow-up errors (here asyncpg's "another operation is in progress") can mislead — the real cause is that the pool's connections belong to another loop. When you see "attached to a different loop" in tests, compare the fixture's loop_scope with the test's.
Verify: a test module that uses a module-loop fixture without the matching pytestmark fails with the different-loop error, confirming the check is needed.
4. Isolate state when sharing a loop¶
A shared loop shares more than fixtures: tasks left running, context variables set at module level, and data in shared resources all carry over between tests. Isolate what matters per test:
@pytest_asyncio.fixture(loop_scope="module")
async def conn(pool):
async with pool.acquire() as c:
tx = c.transaction()
await tx.start()
yield c # each test runs inside its own transaction...
await tx.rollback() # ...rolled back afterwards: no data leaks between tests
A function-scoped fixture on the module loop gives each test its own transaction on the shared pool — fast setup, isolated data. Combine it with a leaked-task check that runs per test, as in detecting leaked tasks in tests, so a task left running by one test does not interfere with the next on the same loop. Randomizing test order (pytest -p randomly) is a quick way to find hidden dependencies between tests that share a loop.
Verify: the module's tests pass in random order, and no rows written by one test are visible to another.
5. Choose scopes by cost and risk¶
Wider scopes trade isolation for speed. Choose per resource:
# function loop (default): unit tests, anything cheap, anything that mutates global state
# module/class loop: tests sharing a pool, a client or an in-process server
# session loop: one expensive dependency for the whole suite (container, migrations)
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def database():
url = await start_test_database() # e.g. a container, migrated once
yield url
await stop_test_database()
Session-scoped loops make every async test in the session share one loop unless they declare otherwise, so keep session-level async fixtures for genuinely expensive, read-mostly resources, and expose per-test isolation (transactions, unique keys) on top of them. Measure before widening scopes: if per-test setup is a few milliseconds, the isolation is worth more than the time. Integration-test patterns with real dependencies are in integration testing async services with real dependencies.
Verify: the suite's slowest setup steps (pytest --durations=10 --durations-min=0.1) justify every scope wider than function.
Verification¶
Loop scopes are configured well when:
- The default function loop is used unless a shared resource justifies more.
- Fixtures and tests that share an object share a loop scope, declared visibly.
- Per-test isolation (transactions, leak checks) exists on shared loops.
- Wider scopes are justified by measured setup cost.
Diagnostic Hook: search CI logs for "attached to a different loop" and ScopeMismatch. Each occurrence is a fixture and test disagreeing about loops — often introduced when a fixture's scope was widened for speed without updating the tests that use it.
Pitfalls & edge cases¶
- Widening a fixture's
scopewithout itsloop_scope. Tested:ScopeMismatch. - Module-loop fixtures used by function-loop tests. Tested: different-loop errors.
- Shared loops without isolation. Tasks and data leak between tests.
- Session loops by default. Every test then shares one loop's leftovers.
Frequently Asked Questions¶
How do I share an async fixture across tests in pytest-asyncio?
Give the fixture scope and loop_scope of the same level, such as module, and run the tests on that loop with pytest.mark.asyncio(loop_scope="module"). In testing that cut 50 database tests from 1.62 s to 0.15 s.
What does ScopeMismatch _function_scoped_runner mean in pytest-asyncio?
A fixture with a wider scope, such as module, is using the default function-scoped event loop. Set its loop_scope to match its scope.
Why do I get 'attached to a different loop' in pytest-asyncio tests?
The fixture created its object on one loop and the test runs on another. Make the test's loop_scope match the fixture's loop_scope.
Should all my async tests share one event loop?
No. Keep the default per-test loop unless a resource is expensive to create, and isolate state per test when you share a loop.
Related¶
- Testing Async Code — up to the topic overview.
- Testing with unittest IsolatedAsyncioTestCase — the stdlib approach, always one loop per test.
- Resilience, Cancellation & Error Handling — the section overview.