Application code commits. It calls session.commit() at the end of a unit of work because that is what application code is supposed to do, and a naive rollback fixture is defeated by the first one: the commit ends the fixture's transaction, the rows become durable, and the next test inherits them. The fix is to give the session a savepoint rather than the outer transaction, so the application's commit releases the savepoint and the fixture's rollback still undoes everything.
Prerequisites
SQLAlchemy >= 2.0, which introducedjoin_transaction_mode. On 1.4 the same effect needs a manualafter_transaction_endlistener.- A database engine with transactional semantics for the statements under test; Postgres qualifies, including DDL.
pytest >= 8.0, and the layering described in database fixtures and transactional tests.
Solution
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
@pytest.fixture(scope="session")
def engine(postgres_dsn):
engine = create_engine(postgres_dsn, pool_pre_ping=True)
yield engine
engine.dispose()
@pytest.fixture
def db_session(engine):
# One connection, one outer transaction — the thing we will roll back.
connection = engine.connect()
outer = connection.begin()
# Bound to THAT connection, not the engine, and joined via savepoints:
# session.commit() now issues RELEASE SAVEPOINT rather than COMMIT.
session = Session(bind=connection, join_transaction_mode="create_savepoint")
try:
yield session
finally:
session.close()
outer.rollback() # undoes every "commit" the test made
connection.close()
def test_order_is_persisted(db_session):
create_order(db_session, customer_id="cus_1", total=1234) # commits inside
assert db_session.query(Order).count() == 1
def test_database_is_clean(db_session):
# Passes in either order only if the previous test's commit was contained.
assert db_session.query(Order).count() == 0
conftest.py files. Its failure appears as order dependence, far from the fixture.Why this works
A savepoint is a named marker inside an open transaction. RELEASE SAVEPOINT discards the marker and keeps the work — but only as far as the enclosing transaction's eventual fate. With join_transaction_mode="create_savepoint", SQLAlchemy opens a savepoint whenever the session begins its own transaction on a connection that already has one, and translates commit() into a release. The application sees normal commit semantics: later queries in the same test see the committed rows. The database sees a transaction that was never committed and is finally rolled back.
Binding to the connection is the half that is easy to get wrong. A session bound to the engine asks the pool for a connection, gets a different one, and commits there — entirely outside the fixture's transaction.
Edge cases and failure modes
- Code that creates its own
Session(engine). It escapes the fixture. Inject the session, or point the application's session factory at the fixture's connection in tests. - A second connection must see the data. A live server or a background thread uses its own connection, and uncommitted data is invisible across connections by definition. Those tests need real commits and a truncation fixture.
- DDL on MySQL.
CREATE TABLEcommits implicitly and ends the outer transaction. Postgres does not have this problem. session.rollback()inside the application. It rolls back to the savepoint, which is correct, and the session opens a new savepoint for subsequent work.- Legacy 1.4 recipes. The
after_transaction_endlistener that restarted a nested transaction is no longer needed on 2.0 and can conflict withcreate_savepoint. Delete it.
What the SQL actually looks like
Turning on statement logging for one test makes the mechanism concrete, and it is the fastest way to confirm a fixture is doing what it claims.
import logging
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
BEGIN (implicit) -- fixture: outer transaction
SAVEPOINT sa_savepoint_1 -- session joins via savepoint
INSERT INTO "order" (customer_id, total) VALUES ('cus_1', 1234)
RELEASE SAVEPOINT sa_savepoint_1 -- application's commit()
SAVEPOINT sa_savepoint_2 -- next unit of work
SELECT count(*) FROM "order" -- sees the committed row
RELEASE SAVEPOINT sa_savepoint_2
ROLLBACK -- fixture teardown: all gone
Every COMMIT the application thought it issued is a RELEASE SAVEPOINT, and the only transaction-ending statement is the final ROLLBACK. If the log shows a bare COMMIT anywhere, something opened a connection outside the fixture, and the log line immediately above it usually names the query that did it.
COMMIT anywhere in this log is the leak. Grepping the test run's SQL log for it is a thirty-second audit of the whole suite.That grep generalises into a permanent guard. A small event listener that fails the test if a real COMMIT reaches the database — outside the handful of tests marked as needing committed data — catches every future regression of the binding at the moment it is introduced, rather than as a mysterious order dependence weeks later:
from sqlalchemy import event
@pytest.fixture(autouse=True)
def forbid_real_commits(request, engine):
if request.node.get_closest_marker("committed_data"):
yield
return
def on_commit(conn):
raise AssertionError("a real COMMIT escaped the transactional fixture")
event.listen(engine, "commit", on_commit)
yield
event.remove(engine, "commit", on_commit)
The listener fires on the engine's commit event, which a savepoint release does not trigger, so it is silent for correctly routed sessions and loud for anything that bypassed them. Pairing it with an explicit committed_data marker for the tests that genuinely need durable rows keeps the exception visible in the code rather than scattered through fixtures.
The async variant
The async engine follows the same structure with awaits in the obvious places, plus one extra constraint: the connection belongs to a loop.
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def async_engine(postgres_dsn):
engine = create_async_engine(postgres_dsn.replace("postgresql://", "postgresql+asyncpg://"))
yield engine
await engine.dispose()
@pytest_asyncio.fixture(loop_scope="session")
async def async_session(async_engine):
async with async_engine.connect() as connection:
outer = await connection.begin()
session = AsyncSession(bind=connection, join_transaction_mode="create_savepoint")
try:
yield session
finally:
await session.close()
await outer.rollback()
loop_scope="session" on both fixtures is not decoration. The engine's pool holds connections registered with the session loop; a per-test fixture running on a function loop would try to use them from a different loop and fail with the errors catalogued in sharing an event loop across a test module.
Pointing the application at the fixture's session
Most real applications do not receive a session as a function argument; they get one from a factory, a dependency-injection container or a framework hook. The fixture has to reach that mechanism, or the application silently writes outside the transaction.
import pytest
from myapp import db
@pytest.fixture(autouse=True)
def route_app_sessions(db_session, monkeypatch):
# The application calls db.get_session(); in tests it gets the fixture's.
monkeypatch.setattr(db, "get_session", lambda: db_session)
# FastAPI: override the dependency rather than patching a module.
import pytest
from fastapi.testclient import TestClient
from myapp.main import app, get_db
@pytest.fixture
def client(db_session):
app.dependency_overrides[get_db] = lambda: db_session
try:
yield TestClient(app)
finally:
app.dependency_overrides.clear()
Both routes converge on the same property: every query the application makes goes through the one connection holding the outer transaction. The FastAPI version is preferable where it applies, because dependency overrides are a supported extension point rather than a patch — the argument made generally in dependency injection for testability.
Cost, measured
The reason to go to this trouble is speed, and it is worth knowing the size of the effect. On a typical Postgres container, a test that truncates six tables in teardown spends thirty to sixty milliseconds on cleanup alone; the same test with this fixture spends well under one millisecond on its ROLLBACK. Across four hundred database tests that is the difference between roughly twenty seconds of pure cleanup and effectively none, before counting the setup savings from never recreating anything.
The less visible benefit is reliability. A truncation fixture that raises halfway — a foreign key it did not know about, a table added last week — leaves the database dirty and fails every subsequent test with an error unrelated to its cause. A rollback cannot half-fail in that way: either the transaction is rolled back, or the connection is dead and the next test gets a fresh one from the pool. That asymmetry is why the pattern survives in large suites long after the performance argument has been forgotten. It also means the fixture needs no maintenance when the schema grows: a new table is covered automatically, where a cleanup routine would need editing.
Frequently Asked Questions
Why do rows survive between tests even though the fixture rolls back? Usually because the session is bound to the engine rather than to the connection that owns the outer transaction, so it checks out a different connection from the pool and commits there. Bind the session to the specific connection on which the outer transaction was begun.
Does this work with the async SQLAlchemy engine?
Yes. AsyncConnection.begin and AsyncSession with join_transaction_mode="create_savepoint" behave identically; the fixture becomes an async generator and the rollback is awaited. Match the fixture's loop scope to the engine's so the connection and its loop share a lifetime.
What about code that opens its own session? It bypasses the fixture entirely and writes outside the rolled-back transaction. Either inject the session so the code uses the fixture's, or configure the application's session factory in tests to bind to the fixture's connection.
Related
- Database Fixtures & Transactional Tests — the wider layering this fixture sits in.
- Testing Alembic Migrations in CI — building the database this fixture connects to.
- Starting Postgres with testcontainers-python — where
postgres_dsncomes from. - factory_boy versus Plain Fixture Builders — writing rows through this session without escaping it.
← Back to Database Fixtures & Transactional Tests