Rolling back database state between async tests

Bind the test session to a connection whose outer transaction is never committed, pass join_transaction_mode="create_savepoint" to the sessionmaker, and roll that transaction back at teardown — every test then starts from an identical database without truncating a table. This is the fixture at the centre of testing async SQLAlchemy applications.

Quick Answer

Truncate or roll back — the cost is not comparable Two teardown strategies. Truncating every table runs one statement per table, takes tens of milliseconds on a schema of any size, must respect foreign-key order or disable constraints, and leaves sequences advanced so identifiers keep climbing across the suite. Rolling back an outer transaction that was never committed is a single statement whose cost is independent of the schema, restores sequences along with everything else, and cannot leave residue because nothing was ever made visible outside the transaction. TRUNCATE every table one statement per table tens of ms on a real schema must respect FK order or disable constraints sequences keep climbing across the suite ROLLBACK the outer transaction a single statement cost independent of the schema sequences roll back too nothing was ever visible to roll back from On a thousand-test suite the difference is roughly a minute of wall clock per run, which is the difference between a suite people run and one they wait for.

Before — truncation between tests:

@pytest_asyncio.fixture(autouse=True)
async def clean_tables(engine):
    yield
    async with engine.begin() as connection:
        for table in reversed(Base.metadata.sorted_tables):
            await connection.execute(text(f'TRUNCATE TABLE "{table.name}" CASCADE'))

After — a never-committed outer transaction:

from collections.abc import AsyncIterator

import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, async_sessionmaker


@pytest_asyncio.fixture
async def connection(engine: AsyncEngine) -> AsyncIterator[AsyncConnection]:
    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]:
    factory = async_sessionmaker(
        bind=connection,                      # bound to the connection, NOT the engine
        expire_on_commit=False,
        join_transaction_mode="create_savepoint",
    )
    async with factory() as session:
        yield session

Two details carry the whole pattern. bind=connection is what puts the session inside the test's transaction rather than drawing a fresh connection from the pool. And join_transaction_mode="create_savepoint" is what lets the code under test commit without committing the outer transaction.

Execution Context & Async Workflow Integration

The fixture works because a connection is a single, ordered conversation with the database. The outer transaction is opened on that conversation; the session joins the same conversation rather than starting a new one; and the rollback ends it. Nothing about that reasoning is async-specific — but two things about asyncio make it easier to get wrong.

The first is loop affinity. The connection belongs to the event loop that created it, so a session-scoped engine handing connections to function-scoped tests works only if the loop is also session-scoped. Configure it once:

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"

The second is that AsyncSession cannot be shared between concurrent tasks. A test exercising concurrency must give each task its own session — and each of those sessions must be bound to its own connection, which means those tasks are not inside the test transaction and their writes will really commit.

# Async — a concurrency test cannot use the rollback fixture for the racing tasks
import asyncio

import pytest


@pytest.mark.asyncio
@pytest.mark.postgres
async def test_concurrent_reservations_conflict(engine, clean_database):
    """Two tasks, two sessions, two real transactions — outside the rollback fixture."""
    factory = async_sessionmaker(engine, expire_on_commit=False)

    async def reserve(seat: int) -> bool:
        async with factory() as session, session.begin():
            return await try_reserve(session, seat)

    results = await asyncio.gather(reserve(1), reserve(1), return_exceptions=True)
    assert sum(1 for r in results if r is True) == 1

That test needs real committed transactions to produce a real conflict, so it uses a clean_database fixture that truncates afterwards rather than the rollback fixture. Keeping the two kinds of test visibly separate — most tests on the fast rollback fixture, a handful of concurrency tests on a genuinely committing one — is clearer than trying to make one fixture serve both.

Why the application’s commit does not break isolation A four-stage sequence. The session is operating inside a SAVEPOINT nested in the never-committed outer transaction. When the code under test calls commit, that releases the savepoint: its work becomes visible to the rest of the outer transaction and to nothing else. SQLAlchemy immediately begins a new savepoint so the session has somewhere to put subsequent work, which is what join_transaction_mode="create_savepoint" arranges. The outer transaction is untouched throughout, so at teardown a single rollback discards every savepoint together with everything else the test did. the session works inside a SAVEPOINT nested in the outer transaction which is never committed the code under test calls commit() RELEASE SAVEPOINT, not COMMIT visible only inside the outer transaction a new savepoint opens immediately arranged by create_savepoint mode the session keeps working normally teardown rolls the outer transaction back every savepoint goes with it Before SQLAlchemy 2.0 this needed a hand-written after_transaction_end listener. join_transaction_mode replaces that listener entirely — delete it when you upgrade.

Resolving Warnings, Errors & Common Mistakes

Error / WarningRoot CauseProduction Fix
Tests pass individually, fail in the suiteA write escaped the transaction — usually a service that built its own session.Make services accept a session. Grep for async_sessionmaker( outside conftest.py and the composition root.
InvalidRequestError: Can't operate on closed transaction inside context managerThe test called session.close() or exited the session context, then used it again.Let the fixture own the session lifecycle; a test should never close it.
ResourceClosedError: This transaction is closed at teardownThe test rolled back the outer transaction itself, usually by calling connection.rollback().Roll back only in the fixture; the test rolls back its own savepoints via the session.
sqlalchemy.exc.InvalidRequestError: A transaction is already begun on this SessionAn older recipe's after_transaction_end listener left in place alongside join_transaction_mode.Delete the listener — 2.0's join_transaction_mode replaces it.
Identifiers keep climbing across the suiteSequences advanced by a truncation-based fixture that has not been removed.Complete the migration to rollback isolation; a rollback restores sequence position.
RuntimeError: Task got Future attached to a different loopThe engine outlived the event loop, or was created at import time.Same scope for loop and engine; build the engine inside a fixture.

Advanced: Testing Code That Manages Its Own Transactions

Some code legitimately controls transaction boundaries — a job that commits per batch, a retry loop that rolls back and tries again, a saga step that must be durable before the next one starts. Those are exactly the behaviours worth testing, and they interact with the fixture in ways worth understanding.

A batch job that commits per chunk works unchanged: each commit releases a savepoint, a new one opens, and the outer rollback still discards everything. What the test cannot observe from inside the same transaction is durability — whether the rows would have survived a crash — because from the test's point of view they are equally invisible either way.

A retry loop that rolls back is more interesting, because a rollback inside a savepoint returns to the savepoint rather than discarding the whole test:

@pytest.mark.asyncio
async def test_retry_loop_recovers_after_conflict(session, monkeypatch):
    """The service's rollback returns to its savepoint; the test's data survives."""
    customer = Customer(email="ada@example.com")
    session.add(customer)
    await session.flush()

    attempts = {"n": 0}

    async def flaky_apply(session, customer_id: int) -> None:
        attempts["n"] += 1
        if attempts["n"] == 1:
            raise OperationalError("serialization failure", None, None)
        session.add(Order(customer_id=customer_id, total=decimal.Decimal("5")))

    await run_with_retries(session, flaky_apply, customer.id, retries=3)

    assert attempts["n"] == 2
    orders = (await session.execute(select(Order))).scalars().all()
    assert len(orders) == 1
    # The customer created before the failing attempt is still here: the rollback
    # returned to the service's savepoint, not to the start of the test.
    assert (await session.get(Customer, customer.id)) is not None

That last assertion is the one worth writing explicitly, because it encodes the behaviour the retry loop depends on: a failed attempt must not discard work done before it. A retry loop that rolls back the whole transaction instead of a savepoint will pass a naive test and lose data in production, and this is where that difference becomes visible.

For a true serialisation failure — the kind SERIALIZABLE produces and covered in setting transaction isolation level per session — the retry must re-read everything, which means it cannot run inside the failed transaction at all. Testing that path needs two real sessions and a committing fixture, as in the concurrency example above.

Three ways a write escapes the rollback Three leaks. A service that constructs its own session from a module-level factory draws a different connection from the pool, so its transaction is genuinely committed and its rows survive teardown — the fix is for services to accept a session rather than build one. A background task started during a test runs after the fixture has rolled back and opens its own session, so its writes land in a committed transaction; tests should await or cancel any task they start. And DDL is not transactional on most databases, so a test that creates a table or alters a type leaves that change behind regardless of the rollback. a service that builds its own session draws a different connection, commits for real fix: services take a session as an argument, never build one a background task started during the test runs after teardown, opens its own session fix: await it, or cancel it, before the test returns DDL issued inside a test CREATE and ALTER are not transactional on most engines fix: do schema work in a session-scoped fixture, not in a test All three share a symptom: the test passes alone and fails inside the suite, or the second run of the suite fails where the first one passed.

Keeping the Fixture Honest

A fixture that silently stops isolating tests is worse than no fixture, because the suite keeps passing. Two cheap checks keep it honest.

The first is a canary test that deliberately writes a row and asserts, in a separate test, that the row is gone. Ordering between tests is not guaranteed, so express it as one test that writes and one that counts, with the count asserting zero:

@pytest.mark.asyncio
async def test_canary_writes_a_row(session):
    session.add(Customer(email="canary@example.com"))
    await session.commit()


@pytest.mark.asyncio
async def test_canary_row_did_not_survive(session):
    found = await session.scalar(
        select(func.count()).select_from(Customer).where(
            Customer.email == "canary@example.com"
        )
    )
    assert found == 0, "a committed row survived the rollback fixture"

If someone later changes the fixture to bind the session to the engine instead of the connection — a one-word edit that looks harmless — this pair fails immediately and names the problem.

The second check is a lint rule, or simply a grep in CI, forbidding async_sessionmaker( and create_async_engine( outside conftest.py and the application's composition root. Every leak listed earlier reduces to a session created somewhere it should not have been, and a grep catches all of them before the suite has to.

Neither check is elaborate, and together they turn "the tests are isolated" from an assumption into something the suite actually asserts.

Frequently Asked Questions

Do I still need the after_transaction_end event listener from older guides?

No — delete it. SQLAlchemy 2.0's join_transaction_mode="create_savepoint" does the savepoint bookkeeping that the listener existed to provide, and keeping both produces InvalidRequestError: A transaction is already begun on this Session.

What if the code under test calls session.rollback()?

It rolls back to the session's current savepoint, not to the start of the test, so work the test did before the code under test ran is preserved. That is the correct behaviour and matches production, where a service rollback ends the service's unit of work rather than everything the process has done.

Can I use this with SQLite?

Yes, with StaticPool so every checkout reuses the single in-memory database. SQLite supports savepoints, so nested transactions work. What it cannot do is exercise concurrency, so any test about locking or serialisation needs a real PostgreSQL regardless of the fixture.

Is the outer transaction holding locks for the whole test?

Yes, and that is fine because nothing else is using the database — each test has its own connection and, under parallel runs, its own database. It does mean a test that deliberately blocks on a lock will hang rather than fail, so give such tests an explicit statement_timeout rather than relying on the suite timeout.