Using savepoints with begin_nested() in AsyncSession

Wrap each statement that is allowed to fail in async with session.begin_nested(): and catch the exception outside that block — PostgreSQL aborts the whole transaction on any error, and only a ROLLBACK TO SAVEPOINT lets it continue. This guide belongs to transaction isolation and commit strategies.

Quick Answer

Without a savepoint, the first IntegrityError in an import poisons the transaction: PostgreSQL refuses every further statement until it is rolled back, and SQLAlchemy reports that as PendingRollbackError.

One failed statement, two outcomes Left: the import inserts a customer, a duplicate email raises IntegrityError, and PostgreSQL marks the transaction aborted; every following statement fails with current transaction is aborted and SQLAlchemy raises PendingRollbackError. Right: each insert runs inside begin_nested, so the failure rolls back to the savepoint, the exception is caught outside the block, and the transaction carries on to commit the rows that succeeded. no savepoint INSERT customer #3 → IntegrityError transaction is now aborted INSERT customer #4 → InFailedSQLTransaction PendingRollbackError on the session async with session.begin_nested() SAVEPOINT; INSERT #3 → IntegrityError ROLLBACK TO SAVEPOINT INSERT #4 continues normally COMMIT keeps every good row PostgreSQL aborts the whole transaction on any error. A savepoint is the only way to continue inside it.

Before — one failure aborts everything that follows:

from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from shop.models import Customer


async def import_customers(session: AsyncSession, rows: list[dict]) -> int:
    imported = 0
    for row in rows:
        try:
            session.add(Customer(**row))
            await session.flush()
            imported += 1
        except IntegrityError:
            continue      # the transaction is already aborted
    await session.commit()
    return imported
# sqlalchemy.exc.PendingRollbackError: This Session's transaction has been rolled back due to
# a previous exception during flush. To begin a new transaction with this Session, first issue
# Session.rollback().

After — a savepoint per row, caught outside the block:

from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from shop.models import Customer


async def import_customers(session: AsyncSession, rows: list[dict]) -> tuple[int, list[str]]:
    imported, rejected = 0, []
    for row in rows:
        try:
            async with session.begin_nested():
                session.add(Customer(**row))
            imported += 1
        except IntegrityError:
            rejected.append(row["email"])
    await session.commit()
    return imported, rejected

Leaving the begin_nested() block flushes the new Customer and releases the savepoint. When the flush fails, the block rolls back to the savepoint and re-raises, and the except clause outside it records the rejection. The outer transaction is intact and commits every row that succeeded.

Execution Context & Async Workflow Integration

PostgreSQL treats a transaction as all-or-nothing in a stricter way than most databases: after any error, the transaction enters an aborted state and every subsequent statement fails with current transaction is aborted, commands ignored until end of transaction block, which asyncpg raises as InFailedSQLTransactionError. The only statements it accepts are ROLLBACK and ROLLBACK TO SAVEPOINT.

What begin_nested() actually does Five steps. Entering the block flushes pending changes, then emits SAVEPOINT with a generated name. The block runs its statements. On success, leaving the block emits RELEASE SAVEPOINT and the changes become part of the outer transaction, not yet committed. On an exception, leaving emits ROLLBACK TO SAVEPOINT, expunges objects added inside the block, expires objects modified inside it, and re-raises. The outer transaction commits or rolls back later as a whole. enter the block flush; SAVEPOINT sa_savepoint_1 pending work is flushed first block runs INSERT / UPDATE ... on success: RELEASE SAVEPOINT sa_savepoint_1 kept, but not committed yet on an exception instead: ROLLBACK TO SAVEPOINT new objects expunged, changed ones expired the exception is re-raised outer COMMIT everything released is saved together Nothing is durable until the outer transaction commits. A released savepoint can still be lost.

SQLAlchemy mirrors that on the session. When a flush fails, the session rolls back its transaction internally and marks itself as needing an explicit rollback(); any further use raises PendingRollbackError until you call it. That is correct and protective — it stops code from assuming earlier work in the transaction survived — and it is also why the naive try/except around a flush cannot continue.

AsyncSession.begin_nested() returns an AsyncSessionTransaction that manages a SQL SAVEPOINT. Entering it flushes any pending changes, so the savepoint marks a clean point, and emits SAVEPOINT sa_savepoint_1. Leaving it normally flushes again and emits RELEASE SAVEPOINT. Leaving it with an exception emits ROLLBACK TO SAVEPOINT, restores the session to its state at the savepoint, and re-raises. The outer transaction was never aborted from PostgreSQL's point of view, because the rollback happened within it.

Session state after a rolled-back savepoint follows the database. Objects added inside the block are expunged, so they are no longer pending. Objects modified inside the block are expired, so their next access reloads current values — which under async means an explicit await session.refresh(obj) rather than attribute access, for the reasons in fixing GreenletSpawnError. Objects untouched inside the block keep their state.

Nothing inside a released savepoint is durable. RELEASE SAVEPOINT merges the work into the outer transaction, which still commits or rolls back as a whole; a crash before the outer commit() loses released work too. A savepoint is a way to undo part of a transaction, not a way to commit part of one. If partial results must survive regardless, commit in chunks instead — the trade-offs are in the parent guide to transaction isolation and commit strategies.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
PendingRollbackError: This Session's transaction has been rolled back due to a previous exception during flushA flush failed with no savepoint, and the session was used again.Put fallible statements inside begin_nested(), or call await session.rollback().
InFailedSQLTransactionError: current transaction is aborted, commands ignored until end of transaction blockA statement ran after an error in the same PostgreSQL transaction.Same fix; the savepoint must wrap the statement that failed.
Exception caught, but the next statement still failsThe try/except was inside the begin_nested() block, so the block never saw the error.Catch outside the block.
IntegrityError raised at commit() instead of inside the blockThe object was added inside the block but flushed later.Leaving the block flushes; make sure the add() is inside it, or await session.flush() inside.
MissingGreenlet reading an object after a savepoint rollbackThe object was modified in the block and expired by the rollback.await session.refresh(obj) before reading it.
InvalidRequestError: ... no nested transaction or similar state errorsCalling commit() on the nested transaction object manually and then leaving the block.Use the context manager and let it release or roll back.
Catch it outside the block Three tiles. Catching inside the block, around the statement, means the savepoint context manager never sees the error, releases a savepoint whose statement failed, and the transaction is still aborted. Catching outside the begin_nested block, inside the loop, is correct: the block rolls back to the savepoint, then the handler runs. Catching around the whole loop rolls back only the last item and abandons the rest. inside the block try: await flush() except: pass RELEASE fails: still aborted around the block try: async with begin_nested() rolls back, then continues around the whole loop one try for all items stops at the first failure The context manager has to see the exception to issue ROLLBACK TO SAVEPOINT.

The inside-versus-outside mistake is the one to watch in review. This version looks almost identical to the correct code and does not work:

async with session.begin_nested():
    try:
        session.add(Customer(**row))
        await session.flush()
    except IntegrityError:
        pass   # the savepoint block never sees the error
# RELEASE SAVEPOINT is attempted on an aborted transaction and fails

The context manager can only roll back to the savepoint if the exception propagates through it. Swallowing the error inside means the block exits "successfully", tries to release a savepoint in a transaction PostgreSQL has already aborted, and fails with a second, more confusing error.

A related subtlety concerns IntegrityError raised by other statements. A unique violation from an UPDATE, a foreign-key violation from a DELETE, or a check-constraint violation all abort the transaction the same way, and all are recoverable with the same pattern. Errors that are not about the data — a lost connection, a serialization failure — are not: the right response to those is a rollback and a retry of the whole unit, as in handling serialization failures with retry logic.

Advanced: Batch Savepoints With Row-Level Fallback

A savepoint per row costs two extra round trips per row — SAVEPOINT and RELEASE — on top of the insert. For a few hundred rows that is invisible. For a hundred thousand it dominates the import. The usual optimisation is to optimise for the common case, where a batch has no bad rows, and fall back to row-by-row only for batches that fail.

Savepoints have a price Bar chart of an illustrative import of 10,000 rows with a few duplicates. One savepoint per row adds two extra round trips per row and is the slowest. A savepoint per batch, retrying row by row only for a failed batch, is much faster. An INSERT ... ON CONFLICT DO NOTHING in batches needs no savepoints at all and is fastest. savepoint per row slowest: 3 round trips per row savepoint per batch of 500, row fallback fast unless many batches fail ON CONFLICT DO NOTHING, batches fastest: no savepoints needed Illustrative. When the database can resolve the conflict itself, let it — savepoints are for everything else.
from collections.abc import Sequence

from sqlalchemy import insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from shop.models import Customer


async def import_in_batches(
    session: AsyncSession, rows: Sequence[dict], batch_size: int = 500
) -> tuple[int, list[dict]]:
    imported, rejected = 0, []
    for start in range(0, len(rows), batch_size):
        batch = rows[start:start + batch_size]
        try:
            async with session.begin_nested():
                await session.execute(insert(Customer), batch)
            imported += len(batch)
            continue
        except IntegrityError:
            pass
        # The batch had at least one bad row: find it, keep the rest.
        for row in batch:
            try:
                async with session.begin_nested():
                    await session.execute(insert(Customer), [row])
                imported += 1
            except IntegrityError:
                rejected.append(row)
    await session.commit()
    return imported, rejected

The batch insert uses Core-style insert(Customer) with a list of dictionaries through the session, which SQLAlchemy executes as an efficient multi-row insert rather than one ORM flush per object. When the data is clean, the cost is one savepoint per five hundred rows.

Before reaching for either pattern, check whether the database can resolve the conflict without an error at all. If "bad row" means "duplicate key", INSERT ... ON CONFLICT DO NOTHING skips duplicates inside a single statement, with no aborted transaction and no savepoints, and RETURNING tells you which rows were inserted. Bulk upserting rows with INSERT ON CONFLICT covers it. Savepoints are for failures the database cannot resolve on its own: check constraints, foreign keys to rows that do not exist, or triggers that raise.

Savepoints in Long Transactions and in Tests

Two places use savepoints heavily enough that their cost and behaviour deserve a closer look: long-running transactions that accumulate many of them, and test suites that use them to isolate tests.

Subtransaction overflow Three bands. Each SAVEPOINT that performs a write gets a subtransaction ID. Each backend caches up to 64 subtransaction IDs in shared memory; beyond that the cache overflows and visibility checks must read the pg_subtrans data from disk-backed storage. On busy systems, and especially on replicas, overflowed transactions cause contention that slows unrelated queries. every writing SAVEPOINT is a subtransaction it gets its own transaction ID, recorded against the parent more than 64 in one open transaction: the cache overflows visibility checks fall back to pg_subtrans lookups for every session that looks keep savepoints per batch, not per row, in long transactions or commit in chunks so no single transaction accumulates hundreds of them

In PostgreSQL, every savepoint that writes anything becomes a subtransaction with its own transaction ID. Each backend caches up to 64 subtransaction IDs for the transaction it is running. Past that, the cache overflows, and every other session checking row visibility against that transaction has to consult pg_subtrans instead of the in-memory cache. On a busy primary this adds contention; on hot-standby replicas it is a well-known cause of sudden, system-wide query slowdowns while the overflowed transaction is open.

The practical rule is to avoid holding hundreds of writing savepoints in one open transaction. The batch pattern above already reduces the count by the batch size. For very large imports, commit in chunks as well, so no single transaction lives long enough to accumulate many:

from sqlalchemy.ext.asyncio import async_sessionmaker


async def import_large_file(Session: async_sessionmaker, rows: list[dict]) -> None:
    chunk = 10_000
    for start in range(0, len(rows), chunk):
        async with Session() as session:
            await import_in_batches(session, rows[start:start + chunk], batch_size=500)
            # import_in_batches commits: at most 20 savepoints per transaction here,
            # plus any row-level fallbacks for failed batches.

Tests use savepoints for the opposite reason: to make every test's writes disappear. The pattern binds the session to a connection with an open outer transaction and join_transaction_mode="create_savepoint", so that when code under test calls commit(), SQLAlchemy releases a savepoint instead of committing, and the fixture rolls back the outer transaction at teardown. It is fast and fully isolating, and it means code that itself uses begin_nested() runs as a savepoint inside a savepoint inside a transaction — which PostgreSQL handles without complaint. The fixture is described in rolling back database state between async tests.

Frequently Asked Questions

Does begin_nested() commit anything?

No. Releasing a savepoint makes its work part of the outer transaction, which is committed or rolled back later as a whole. Only session.commit() on the outer transaction makes anything durable.

Can I use begin_nested() without an outer transaction?

The session autobegins an outer transaction on first use, so calling begin_nested() on a fresh session starts a transaction and a savepoint inside it. You still need to commit the session afterwards.

Why do I get PendingRollbackError after catching IntegrityError?

Because the failed flush happened outside a savepoint, so the session rolled back its whole transaction. Either wrap the fallible statement in begin_nested() and catch outside it, or call await session.rollback() and start again.

Are savepoints supported on SQLite and MySQL?

Both support SAVEPOINT, but pysqlite and aiosqlite need their transaction handling adjusted for savepoints to work reliably, and MySQL cannot roll back DDL inside one. The pattern here is written for PostgreSQL with asyncpg.