Using pytest-asyncio fixtures with AsyncSession

Set asyncio_mode = "auto" and asyncio_default_fixture_loop_scope = "session", then give the engine fixture the same scope as the loop — almost every "attached to a different loop" failure in an async SQLAlchemy suite is a connection outliving the loop that created it. This is the fixture-plumbing companion to testing async SQLAlchemy applications.

Quick Answer

What belongs at which scope Three scopes. Session scope is where the event loop and the engine belong: creating an engine per test would rebuild a connection pool thousands of times, and the loop must be at least as wide as anything holding its connections. Function scope is where the connection and the session belong, because that is the unit of isolation — one transaction per test, rolled back at the end. Module scope is generally a trap: it is wide enough to leak state between tests in the same file and narrow enough to keep rebuilding the pool, giving the disadvantages of both. session scope the event loop the engine and its pool schema creation function scope the connection the session one transaction per test module scope wide enough to leak state narrow enough to rebuild the pool avoid for database fixtures The invariant is one line: nothing that owns a connection may outlive the loop that created it. Every "different loop" error is a violation of exactly that.

Before — a hand-written loop fixture and mismatched scopes:

# conftest.py — the shape that produces "attached to a different loop"
import asyncio

import pytest


@pytest.fixture(scope="session")
def event_loop():                       # deprecated, and the source of the problem
    loop = asyncio.new_event_loop()
    yield loop
    loop.close()


engine = create_async_engine(TEST_URL)  # created at import, before any loop exists

After — configuration instead of fixtures:

# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
# conftest.py — no event_loop fixture at all
from collections.abc import AsyncIterator

import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine

from myapp.models import Base


@pytest_asyncio.fixture(scope="session")
async def engine() -> AsyncIterator[AsyncEngine]:
    engine = create_async_engine("postgresql+asyncpg://app:secret@localhost/orders_test")
    async with engine.begin() as connection:
        await connection.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()

Two changes carry it. The hand-written event_loop fixture is gone — overriding it has been deprecated, and the configuration option replaces it. And the engine moved from module scope into a fixture, so it is created after the loop exists rather than before.

Execution Context & Async Workflow Integration

An asyncio connection is bound to the loop it was created on: its socket is registered with that loop's selector, and its futures are that loop's futures. Handing it to a different loop is not a SQLAlchemy limitation but an asyncio one, and the error message — Task got Future attached to a different loop — names the mechanism rather than the cause.

That gives a single rule: the loop must be at least as long-lived as anything holding a connection. A session-scoped engine with a function-scoped loop violates it, and does so intermittently — the first test creates connections on loop A, the pool keeps them, and the second test on loop B fails when it reuses one.

There are two ways to satisfy the rule and they are both legitimate:

# Option 1 — a long-lived loop, so the pool is genuinely reused (preferred)
# pyproject.toml: asyncio_default_fixture_loop_scope = "session"
@pytest_asyncio.fixture(scope="session")
async def engine() -> AsyncIterator[AsyncEngine]:
    engine = create_async_engine(TEST_URL)
    yield engine
    await engine.dispose()
# Option 2 — a per-test loop, so the engine must be per-test and must not pool
from sqlalchemy.pool import NullPool


@pytest_asyncio.fixture
async def engine() -> AsyncIterator[AsyncEngine]:
    engine = create_async_engine(TEST_URL, poolclass=NullPool)
    yield engine
    await engine.dispose()

Option 1 is faster because the pool survives across tests. Option 2 is simpler to reason about and is worth reaching for when a suite has some intractable per-test loop requirement — but NullPool is not optional there: without it, the engine's pool holds connections from the previous test's loop and the failure returns.

The same reasoning explains why an engine must not be created at import time. Module-level code runs during collection, before the plugin has installed any loop, so the engine's pool is created against whatever loop happens to exist — often none. This is the same failure the Celery integration guide describes for prefork workers: an engine that predates the execution context it will be used from.

Auto mode, strict mode, and the silent skip Two plugin modes. In auto mode every async def test and fixture is collected and run as an asyncio test with no decoration, which removes an entire category of mistake at the cost of not coexisting with another async plugin. In strict mode — the default — each async test needs an explicit pytest.mark.asyncio and each fixture needs pytest_asyncio.fixture; an undecorated async test is not run at all. It is reported as passed rather than skipped in some versions, which means a whole file can be silently dead. Auto mode is the right default for a suite that only uses asyncio. asyncio_mode = "auto" every async test and fixture just runs no marker, no decorator right for an asyncio-only suite cannot coexist with trio plugins asyncio_mode = "strict" (the default) pytest.mark.asyncio on every test pytest_asyncio.fixture on every fixture an undecorated async test never runs and may still report as passing The silent skip is the reason to prefer auto mode: a test that never runs is worse than a failing one, and nothing in the output tells you it happened.

Resolving Warnings, Errors & Common Mistakes

Error / WarningRoot CauseProduction Fix
RuntimeError: Task <...> got Future <...> attached to a different loopA pooled connection created on one loop is being used on another.Give the loop and the engine the same scope, or use NullPool with a per-test engine.
DeprecationWarning: The event_loop fixture provided by pytest-asyncio has been redefinedA hand-written event_loop fixture.Delete it and set asyncio_default_fixture_loop_scope instead.
An async test reports as passing without runningStrict mode with no @pytest.mark.asyncio.Set asyncio_mode = "auto", or add the marker to every async test.
fixture 'session' called directlyAn async def fixture declared with @pytest.fixture instead of @pytest_asyncio.fixture in strict mode.Use @pytest_asyncio.fixture, or switch to auto mode where either works.
ScopeMismatch: You tried to access the function scoped fixture with a session scoped requestA session-scoped fixture depends on a function-scoped one.Dependencies may only point from narrow to wide; move the shared object outward.
RuntimeError: Event loop is closed during teardownThe engine was disposed after the loop closed, or never disposed at all.await engine.dispose() inside the fixture, before it yields control back.

Advanced: Fixture Layering for a Real Suite

A suite of any size ends up with more than three fixtures, and the layering is worth designing once rather than growing by accretion.

# conftest.py — the full chain, with each layer doing exactly one thing
from __future__ import annotations

import os
from collections.abc import AsyncIterator

import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import (
    AsyncConnection, AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine,
)

from myapp.models import Base


@pytest_asyncio.fixture(scope="session")
async def engine() -> AsyncIterator[AsyncEngine]:
    """One engine, one pool, one schema build for the whole run."""
    worker = os.environ.get("PYTEST_XDIST_WORKER", "gw0")
    url = f"postgresql+asyncpg://app:secret@localhost/orders_test_{worker}"
    engine = create_async_engine(url)
    async with engine.begin() as connection:
        await connection.run_sync(Base.metadata.drop_all)
        await connection.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()


@pytest_asyncio.fixture
async def connection(engine: AsyncEngine) -> AsyncIterator[AsyncConnection]:
    """One never-committed transaction per test."""
    async with engine.connect() as connection:
        transaction = await connection.begin()
        yield connection
        await transaction.rollback()


@pytest_asyncio.fixture
async def session(connection: AsyncConnection) -> AsyncIterator[AsyncSession]:
    """The session every test and every service under test shares."""
    factory = async_sessionmaker(
        bind=connection, expire_on_commit=False,
        join_transaction_mode="create_savepoint",
    )
    async with factory() as session:
        yield session


@pytest_asyncio.fixture
async def seeded(session: AsyncSession) -> AsyncSession:
    """Reference data most tests want, inside the same transaction."""
    session.add_all([Currency(code="GBP"), Currency(code="USD")])
    await session.flush()
    return session

Three properties make this layering hold up. Each fixture has exactly one responsibility, so a change to isolation strategy touches one function. Fixtures depend only outward — function scope may depend on session scope, never the reverse — which is what ScopeMismatch enforces. And the seed fixture builds its data inside the test transaction rather than in a session-scoped one, so seeded rows roll back with everything else and a test that mutates them cannot affect the next.

The one thing deliberately absent is an autouse fixture. An autouse=True database fixture makes every test in the suite — including pure unit tests with no database involvement — pay for a connection checkout. Requesting session explicitly keeps that cost visible and keeps the fast tests fast.

Fixture resolution order for one test Five stages, in the order pytest resolves them for a single test. The event loop is installed at session scope, before anything asynchronous exists. The engine is created on that loop and the schema is built once. For each test a connection is checked out and an outer transaction begins. The session is then constructed bound to that connection. Teardown unwinds precisely in reverse: the session closes, the transaction rolls back, the connection returns to the pool, and only at the very end of the session are the engine and the loop disposed of. Any fixture that creates a connection outside this chain — a nested engine, a service building its own session — falls outside the unwinding and leaks. event loop installed session scope before anything async exists engine created, schema built session scope, once on that loop connection + outer transaction function scope the unit of isolation session bound to the connection function scope create_savepoint mode teardown unwinds in reverse session, transaction, connection, engine, loop Anything creating a connection outside this chain is outside the unwinding too, which is why a service that builds its own session leaks rows past the rollback.

Running the Suite in Parallel

Rollback isolation makes tests independent, which makes pytest -n auto safe as soon as each worker has its own database. The PYTEST_XDIST_WORKER variable in the engine fixture above is the whole mechanism; the databases themselves are created once, before the run.

# create one database per worker, once, before the suite runs
for i in $(seq 0 7); do
  createdb "orders_test_gw${i}" 2>/dev/null || true
done
pytest -n 8

Two considerations decide whether this is worth doing. Schema setup happens once per worker rather than once per run, so a slow create_all or a full migration run is multiplied by the worker count — with migrations that can dominate, and the usual answer is to build one template database and createdb --template from it, which is close to instantaneous.

And the database server itself becomes a shared resource: eight workers each holding an open transaction is eight connections plus whatever the pool keeps, which is trivial for a local PostgreSQL and worth checking against max_connections in CI. The arithmetic is the same as for an application fleet, and the reasoning in tuning connection pools for cloud databases applies unchanged — a test run is just another client with a connection budget.

The failure mode to watch for is a test that passes serially and fails under -n, which nearly always means shared state that is not the database: a temporary file at a fixed path, a module-level cache, a fixed port. Those are worth fixing rather than working around, because the same shared state will eventually cause a flake in the serial suite too.

Frequently Asked Questions

Should I use asyncio_mode = "auto" or strict?

Auto, unless the suite also uses another async framework. Strict mode's failure — an undecorated async test that never runs and is not reported as skipped — is silent, and silent is the worst property a test suite can have. Auto mode removes the decoration entirely.

Can the engine fixture be session-scoped if my tests are function-scoped?

Yes, and it should be — that is the arrangement that lets the connection pool be reused. What must match is the loop scope and the engine scope, not the engine scope and the test scope. With a session-scoped loop, a session-scoped engine serving function-scoped connections is exactly right.

Why did my suite start failing after upgrading pytest-asyncio?

The most likely cause is the removal of the overridable event_loop fixture. If your conftest.py defines one, delete it and set asyncio_default_fixture_loop_scope instead. The second likeliest is that the default fixture loop scope changed, leaving a session-scoped engine paired with a function-scoped loop.

Do I need NullPool in tests?

Only if the engine is function-scoped or the loop is narrower than the engine. With a session-scoped loop and engine, pooling is both safe and desirable — it is a large part of why the suite is fast.