Transaction Management and Rollback in FastAPI

Key takeaways:

  • The transaction boundary belongs to the yield dependency, not to the endpoint body.
  • Commit in an else clause and roll back in an except clause, so a raised HTTPException cannot commit half a write.
  • flush() sends SQL and populates primary keys; only commit() makes it durable.
  • A commit() inside the endpoint converts every later failure into a partial write — shown below with real numbers.
  • Use begin_nested() (a SAVEPOINT) when part of the work must survive a failure of the rest.

This page continues Async Database Sessions, which covers where the engine and session factory live. Here we are concerned with one narrower question: which lines of code are allowed to end a transaction.

Transaction boundary owned by the dependency versus the endpoint When the yield dependency owns the boundary, a failure anywhere in the endpoint rolls the whole unit back. When the endpoint commits mid-way, the first write is already durable and the failure leaves the database inconsistent. Dependency owns the boundary debit alice credit bob check fails rollback: all alice 100 bob 0 (unchanged) Endpoint commits mid-way debit alice commit() check fails nothing to undo alice -50 bob 0 (money destroyed) Both rows below are measured output from the same seeded database.
The only difference between the two rows is where commit() is written.

The Problem This Solves

A handler makes two or three related writes. Something between them fails — a business rule rejects the operation, a constraint fires, an upstream call times out. What is left in the database afterwards depends entirely on where the commit was written, and in most codebases that answer varies from handler to handler because each one was written by a different person on a different day.

The goal is a rule with no exceptions: one request, one transaction, and exactly one piece of code allowed to end it.

Why It Happens: yield Dependencies Wrap the Path Operation

FastAPI's dependencies with yield are not just a setup/teardown convenience — they are a context that encloses the path operation. The code before yield runs first, the endpoint body runs, then the code after yield runs. Crucially, when the endpoint raises, the exception propagates through the generator, so an except clause around the yield sees it.

That is precisely the shape of a transaction:

async def get_session() -> AsyncIterator[AsyncSession]:
    """The transaction boundary. The endpoint never commits; this dependency does, once."""
    async with Session() as session:
        try:
            yield session
        except Exception:
            await session.rollback()
            raise
        else:
            await session.commit()

The else clause is doing real work. commit() runs only when the endpoint returned without raising. Writing the commit after the try block instead — unconditionally — would commit whatever was pending even on the failure path, which is the single most common way this pattern is broken in practice.

The other half of the mechanism is the distinction between flush and commit. flush() emits the pending INSERT/UPDATE statements so that constraints fire and autogenerated primary keys become available, but it remains inside the open transaction. Nothing is durable, and a rollback erases it. Endpoints should flush freely and never commit.

The Fix: One Transaction per Request

The example runs against in-memory SQLite through aiosqlite, seeded with alice holding 100 and bob holding 0. Every scenario below transfers 150 — an overdraft that must fail — and we look at what survives.

"""Where the transaction boundary lives."""
from typing import Annotated, AsyncIterator

from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.pool import StaticPool

engine = create_async_engine(
    "sqlite+aiosqlite://",
    poolclass=StaticPool,
    connect_args={"check_same_thread": False},
)
Session = async_sessionmaker(engine, expire_on_commit=False)

SessionDep = Annotated[AsyncSession, Depends(get_session)]

app = FastAPI()


async def _debit_credit(session: AsyncSession, amount: int) -> Account:
    alice = await session.get(Account, "alice")
    bob = await session.get(Account, "bob")
    alice.balance -= amount
    bob.balance += amount
    session.add(Ledger(note=f"transfer {amount}"))
    await session.flush()  # SQL is sent, but nothing is committed yet
    return alice


@app.post("/transfer/dependency-owned")
async def transfer_dependency_owned(session: SessionDep, amount: int = 150) -> dict:
    """Correct: writes are flushed, the check fails, the dependency rolls the whole unit back."""
    alice = await _debit_credit(session, amount)
    if alice.balance < 0:
        raise HTTPException(status_code=409, detail=f"overdraft: alice would be {alice.balance}")
    return {"transferred": amount}

The real transcript, with /state reading the table back after each attempt:

$ GET /state
200 OK
{
  "accounts": {
    "alice": 100,
    "bob": 0
  },
  "ledger": []
}

$ POST /transfer/dependency-owned?amount=150
409 Conflict
{
  "detail": "overdraft: alice would be -50"
}

$ GET /state
200 OK
{
  "accounts": {
    "alice": 100,
    "bob": 0
  },
  "ledger": []
}

$ POST /transfer/dependency-owned?amount=40
200 OK
{
  "transferred": 40
}

$ GET /state
200 OK
{
  "accounts": {
    "alice": 60,
    "bob": 40
  },
  "ledger": [
    "transfer 40"
  ]
}

Note what happened in the failing case. Both balance updates and the ledger insert were flushed to the database — the SQL genuinely executed, which is why alice.balance was -50 when the check read it. The HTTPException then propagated out of the endpoint, through the generator's except clause, and the rollback erased all three statements. The follow-up /state shows 100 and 0, exactly as before. The 409 response was still produced normally, because raising and rolling back are independent concerns.

The successful transfer of 40 confirms the same boundary commits when it should: both balances moved and the ledger row is there.

The Failure: a Commit in the Middle

Now the same operation with the commit moved into the handler — a change that looks harmless and is not:

@app.post("/transfer/commits-in-endpoint")
async def transfer_commits_in_endpoint(session: SessionDep, amount: int = 150) -> dict:
    """Wrong: the endpoint commits the debit, so the failure leaves money destroyed."""
    alice = await session.get(Account, "alice")
    alice.balance -= amount
    await session.commit()  # <-- the partial write is now durable
    bob = await session.get(Account, "bob")
    if alice.balance < 0:
        raise HTTPException(status_code=409, detail=f"overdraft: alice would be {alice.balance}")
    bob.balance += amount
    return {"transferred": amount}
$ POST /reset
200 OK
{
  "status": "seeded"
}

$ POST /transfer/commits-in-endpoint?amount=150
409 Conflict
{
  "detail": "overdraft: alice would be -50"
}

$ GET /state
200 OK
{
  "accounts": {
    "alice": -50,
    "bob": 0
  },
  "ledger": []
}

The API returned the same 409. The client sees an identical failure and will reasonably assume nothing happened. But alice is now at -50 and bob is unchanged: 150 units have simply ceased to exist. The dependency's rollback still ran — it just had nothing left to undo, because commit() had already ended the transaction that contained the debit.

This is why "search the codebase for commit()" is a productive code review. Every occurrence outside the session dependency is a candidate partial write, and the damage is silent — no error, no log line, just a row that is wrong forever.

Explicit Blocks and Savepoints

Two variants are worth having in your vocabulary.

When work happens outside a request — a startup job, a task worker, a script — there is no dependency to own the boundary, so use session.begin() as a context manager. It commits on clean exit and rolls back on any exception:

@app.post("/transfer/explicit-begin")
async def transfer_explicit_begin(amount: int = 150) -> dict:
    """`async with session.begin()` rolls back on any exception leaving the block."""
    try:
        async with Session() as session, session.begin():
            alice = await _debit_credit(session, amount)
            if alice.balance < 0:
                raise ValueError(f"overdraft: alice would be {alice.balance}")
    except ValueError as exc:
        return {"rolled_back": True, "reason": str(exc)}
    return {"transferred": amount}
$ POST /transfer/explicit-begin?amount=150
200 OK
{
  "rolled_back": true,
  "reason": "overdraft: alice would be -50"
}

$ GET /state
200 OK
{
  "accounts": {
    "alice": 100,
    "bob": 0
  },
  "ledger": []
}

Note the 200 status: the exception never left the endpoint, yet the balances are untouched. The rollback is driven by the begin() block exiting with an exception, entirely independently of what the HTTP layer decides to return.

The second variant answers a real requirement — "we must record that the attempt happened, even though the attempt failed". A blanket rollback would erase the audit row along with everything else. begin_nested() issues a SAVEPOINT so you can undo a sub-range:

@app.post("/transfer/savepoint")
async def transfer_savepoint(session: SessionDep, amount: int = 150) -> dict:
    """A nested savepoint undoes only the risky part; the audit row still commits."""
    session.add(Ledger(note="attempt logged"))
    rolled_back = False
    try:
        async with session.begin_nested():
            alice = await _debit_credit(session, amount)
            if alice.balance < 0:
                raise ValueError("overdraft")
    except ValueError:
        rolled_back = True
    return {"savepoint_rolled_back": rolled_back}
$ POST /transfer/savepoint?amount=150
200 OK
{
  "savepoint_rolled_back": true
}

$ GET /state
200 OK
{
  "accounts": {
    "alice": 100,
    "bob": 0
  },
  "ledger": [
    "attempt logged"
  ]
}

Balances unchanged, attempt logged persisted. The savepoint rolled back the transfer; the outer transaction continued and the dependency committed it on the way out. This is also the mechanism behind per-test rollback fixtures, described in Testing with Async Database Fixtures.

Verification

The assertion that matters is not "the endpoint returned 409" but "the database is unchanged". Test both:

async def test_failed_transfer_changes_nothing(client):
    before = (await client.get("/state")).json()
    failed = await client.post("/transfer/dependency-owned", params={"amount": 150})
    assert failed.status_code == 409
    assert (await client.get("/state")).json() == before

Two more checks are worth adding permanently. Grep for stray boundaries in CI, since this is a rule a linter can hold for you:

# Any commit outside the session dependency is a partial-write risk.
grep -rn "\.commit()" app/ --include="*.py" | grep -v "app/db/session.py"

And in production, alert on ROLLBACK rate rather than only on 5xx. A handler that quietly rolls back on every request because a constraint always fires will look healthy in your HTTP metrics while writing nothing at all.

Trade-offs and When Not To

Long transactions hold connections. One transaction per request is correct, but a request that fans out to a slow upstream while holding an open transaction keeps a pooled connection and its locks for the whole call. Do the external I/O before you open the write path, or you will meet the exhaustion described in Fixing asyncpg Pool Exhaustion.

Rollback does not undo side effects. Only the database is transactional. Emails sent, payments captured and cache entries written during a rolled-back request stay done. Defer those to a background task queued only on success — see Background Task Processing and, for the idempotency this implies, Retry and Idempotency for Tasks.

Savepoints have a cost. They are cheap but not free, and heavy nesting inside a loop generates a lot of round trips. Reach for one when a specific sub-operation must be recoverable, not as a default wrapper.

Batch jobs may want many transactions. "One transaction per request" is a rule for requests. A job importing a million rows should commit in batches so a failure at row 999,999 does not discard everything, and that job should not be running inside a web handler anyway.

SQLite is not Postgres. The transcripts above are real, but SQLite's isolation and locking differ from Postgres and MySQL. The boundary placement transfers exactly; concurrency behaviour under load must be tested against your real engine.

FAQ

Should the endpoint or the dependency call commit? The dependency. A yield dependency wraps the whole path operation, so committing after the yield means one transaction per request that commits only if the endpoint returned normally. An endpoint that commits mid-way makes every later failure a partial write.

What is the difference between flush and commit?flush sends the pending INSERT and UPDATE statements to the database so constraints and generated primary keys take effect, but it stays inside the open transaction and is undone by a rollback. commit ends the transaction and makes everything durable.

Does raising HTTPException roll back my transaction? Only if something rolls it back. HTTPException is an ordinary exception, so a yield dependency that commits in an else clause or rolls back in an except clause will handle it correctly, while a dependency that commits unconditionally after the yield will commit the partial work.

Do I need to call rollback explicitly if I use the session as a context manager? Not for correctness, because AsyncSession.close discards any uncommitted transaction when the async with block exits. An explicit rollback in an except clause is still worth writing because it releases the database locks immediately and makes the intent obvious to readers.

How do I keep an audit row when the main work fails? Put the risky work inside session.begin_nested, which issues a SAVEPOINT. Rolling back to the savepoint undoes only that block, leaving earlier statements in the outer transaction intact so they commit with the request.

Why did my rollback not undo anything? Almost always because a commit already happened earlier in the request, so the rollback only covers the statements issued after it. Search the handler and its helpers for stray commit calls; the transaction boundary must exist in exactly one place.