Transactions, Savepoints, Isolation

Updated 6 interview angles 6 min read source
On this page14
  1. The default model
  2. Explicit begin
  3. Savepoints — begin_nested()
  4. Tests + transactional rollback
  5. Isolation level
  6. Optimistic locking with version counters
  7. Pessimistic locking — SELECT ... FOR UPDATE
  8. Two-phase commit (XA)
  9. Common error: serialization failure
  10. Deadlocks
  11. autocommit mode (legacy)
  12. Common pitfalls
  13. Common interview confusions
  14. Interview angle

Transactions, Savepoints, Isolation

SQLAlchemy’s transaction handling sits on top of the DB’s. The session abstracts most of it; you should know the underlying mapping for interview questions and for debugging.

For pure DB-side transaction theory (isolation levels, ACID), see Transactions and isolation levels. This file is the SQLAlchemy-specific side.

The default model

python
with Session(engine) as session:
    session.add(user)
    session.commit()

Maps roughly to:

sql
BEGIN;
INSERT INTO users ...;
COMMIT;

The session autobegins on first statement, autoflushes before queries, and COMMITs on session.commit().

Explicit begin

python
with Session(engine) as session:
    with session.begin():
        session.add(user)
    # commits at end of inner block (or rolls back on exception)

The inner context manager makes the commit/rollback explicit and removes the need for try/except around the commit. This is the recommended pattern.

For Core-style:

python
with engine.begin() as conn:
    conn.execute(insert(users).values(name="alice"))
# auto-commits on success, rolls back on exception

Savepoints — begin_nested()

A savepoint is a “subtransaction” that can be rolled back without aborting the outer transaction.

python
with session.begin():
    session.add(user1)

    try:
        # SAVEPOINT sp1
        with session.begin_nested():
            session.add(user2)
            # might raise
            do_risky_thing()
        # RELEASE SAVEPOINT sp1
    except Exception:
        pass
    # if it raised: ROLLBACK TO SAVEPOINT sp1
    # user1 is still in the outer transaction

    session.add(user3)
# outer COMMIT (user1 + user3)

SQL produced:

sql
BEGIN;
INSERT user1;
SAVEPOINT sp1;
INSERT user2;
-- on exception: ROLLBACK TO SAVEPOINT sp1
-- on success: RELEASE SAVEPOINT sp1
INSERT user3;
COMMIT;

Use cases:

  • Try an optional operation; on failure continue.
  • Per-row error handling in a batch.
  • Tests that need rollback to a known state without losing test setup.

Tests + transactional rollback

python
@pytest.fixture
def db_session(engine):
    connection = engine.connect()
    trans = connection.begin()
    session = Session(bind=connection)

    yield session

    session.close()
    # discard everything done in the test
    trans.rollback()
    connection.close()

Pattern:

  1. Open a connection + outer transaction.
  2. Run the test inside; commits in the test become savepoints (SQLAlchemy uses begin_nested for the session inside).
  3. Rollback the outer transaction at teardown.

For modern SQLAlchemy 2.0 with savepoints, the more robust pattern wraps the session in a SAVEPOINT-restarting event listener (the “join_transaction_mode=‘create_savepoint’” pattern). See Testing Patterns.

Isolation level

python
# Engine-level (applies to all new connections)
engine = create_engine(
    "postgresql+psycopg://...",
    isolation_level="REPEATABLE READ",
)

# Per-connection
with engine.connect().execution_options(isolation_level="SERIALIZABLE") as conn:
    ...

# Per-session
with Session(engine, info={...}) as session:
    session.connection(execution_options={"isolation_level": "SERIALIZABLE"})
    ...

Levels (DB-dependent):

Level Phenomena prevented
READ UNCOMMITTED none (dirty reads possible)
READ COMMITTED dirty reads
REPEATABLE READ dirty reads, non-repeatable reads
SERIALIZABLE dirty reads, non-repeatable reads, phantom reads

Defaults:

  • Postgres: READ COMMITTED
  • MySQL/InnoDB: REPEATABLE READ
  • SQL Server: READ COMMITTED
  • Oracle: READ COMMITTED

For SERIALIZABLE workloads, be prepared for serialization failures (Postgres 40001) — wrap operations in retry logic.

Optimistic locking with version counters

python
class Document(Base):
    __tablename__ = "documents"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    version_id: Mapped[int] = mapped_column(default=1)

    __mapper_args__ = {
        "version_id_col": version_id,
    }

On UPDATE, SQLAlchemy issues:

sql
UPDATE documents
SET title = ..., version_id = version_id + 1
WHERE id = ? AND version_id = ?     -- the version you loaded

If another transaction updated the row between your load and your commit, the WHERE matches 0 rows and SQLAlchemy raises StaleDataError. Handle by retrying:

python
for attempt in range(3):
    try:
        doc = session.get(Document, doc_id)
        doc.title = "new"
        session.commit()
        break
    except StaleDataError:
        session.rollback()
        continue

Optimistic locking is right when conflicts are rare. For high-conflict scenarios, use SELECT FOR UPDATE.

Pessimistic locking — SELECT ... FOR UPDATE

python
stmt = select(Account).where(Account.id == 42).with_for_update()
account = session.scalars(stmt).one()
account.balance -= 100
session.commit()

with_for_update() adds FOR UPDATE to the SELECT, holding a row-level lock until commit/rollback. Other transactions trying to update the same row wait.

Options:

python
.with_for_update(nowait=True)             # error immediately if locked
.with_for_update(skip_locked=True)        # skip locked rows (work queue pattern)
.with_for_update(of=Account)               # lock only specific table in a join
.with_for_update(read=True)                # FOR SHARE — read lock, weaker

skip_locked=True is the canonical pattern for “atomic job dequeue”:

python
stmt = (
    select(Job)
    .where(Job.status == "pending")
    .order_by(Job.id)
    .limit(1)
    .with_for_update(skip_locked=True)
)
job = session.scalars(stmt).first()
if job:
    job.status = "processing"
    session.commit()

Multiple workers running this concurrently each pick a different row.

Two-phase commit (XA)

For distributed transactions spanning multiple databases:

python
session = Session(engine, twophase=True)

Most apps don’t need this — coordinated commits across DBs are operationally painful. Prefer event-driven / saga patterns for cross-service consistency.

Common error: serialization failure

Postgres SERIALIZABLE may abort transactions that would violate serializability:

text
psycopg.errors.SerializationFailure: could not serialize access due to concurrent update

In SQLAlchemy this surfaces as OperationalError. The right response is retry the whole transaction:

python
from sqlalchemy.exc import OperationalError

for attempt in range(3):
    try:
        with Session(engine) as session, session.begin():
            do_work(session)
        break
    except OperationalError as e:
        if "could not serialize" not in str(e):
            raise
        # exponential backoff
        time.sleep(0.1 * (2 ** attempt))
else:
    raise RuntimeError("retries exhausted")

Use a library (tenacity) instead of hand-rolled retries in production.

Deadlocks

Two transactions waiting on each other’s locks. The DB picks one to abort:

text
DeadlockDetected: deadlock detected

SQLAlchemy doesn’t auto-retry. Same retry pattern as serialization failures.

Prevent deadlocks:

  • Acquire locks in consistent order across transactions.
  • Keep transactions short.
  • Avoid long lock-holding (e.g. SELECT FOR UPDATE followed by network I/O).

autocommit mode (legacy)

python
# Legacy 1.x — discouraged
engine = create_engine("...", isolation_level="AUTOCOMMIT")

Every statement is its own transaction. Useful for VACUUM, DDL on Postgres, or any operation that can’t run inside a transaction. For application code, stick with the regular transaction model.

Common pitfalls

  • Multiple commits in one logical operation — if you commit halfway through and the second half fails, you’ve partially applied the change. Wrap the whole thing in one transaction.
  • Long-running transactions — hold locks, bloat the WAL, block other writers. Keep transactions short; commit after each unit of work.
  • SELECT FOR UPDATE without an index on the WHERE column — locks every row scanned, not just matched.
  • Forgetting session.rollback() after exception — session enters PendingRollbackError state, next op fails until rolled back. Use with session.begin() so it auto-rolls.
  • Catching IntegrityError and continuing in the same transaction — Postgres aborts the transaction; subsequent statements fail until rollback.

Common interview confusions

  • session.commit() finishes the transaction.” — yes, in SQLAlchemy. But on the DB side, every transaction starts implicitly on the next statement (autocommit off), so the session begins a new one transparently.
  • “Isolation level is set per query.” — typically per connection or per session. Some DBs allow setting per-transaction (SET TRANSACTION ISOLATION LEVEL ...).
  • begin_nested runs a separate transaction.” — it runs a SAVEPOINT inside the same transaction. Rollback affects only the savepoint, not the outer transaction.

Interview angle 6

  • “How do savepoints work in SQLAlchemy?”session.begin_nested() issues SAVEPOINT name; exit of block does RELEASE or ROLLBACK TO. Lets nested ops fail without aborting the outer transaction.
  • “How do you set isolation level?”engine = create_engine(..., isolation_level="SERIALIZABLE") for the engine; or conn.execution_options(isolation_level="...") per connection.
  • “Optimistic vs pessimistic locking — which does SQLAlchemy support?” — both. Optimistic via __mapper_args__["version_id_col"] (UPDATE checks version, raises StaleDataError on conflict). Pessimistic via select(...).with_for_update() (DB-level row lock).
  • “How do you implement a work queue with SELECT FOR UPDATE SKIP LOCKED?” — query: select(Job).where(status=='pending').order_by(id).limit(1).with_for_update(skip_locked=True). Multiple workers each grab a different row atomically.
  • “What’s a serialization failure?” — Postgres SERIALIZABLE detects that committing would violate serializability and aborts. SQLAlchemy raises OperationalError. The fix is retry with backoff — don’t try to handle in-place.
  • “How would you ensure a long-running batch script doesn’t hold one giant transaction?” — chunk into N-row batches; commit per batch. Each commit closes the transaction, releases locks, lets concurrent writers in.