Backend · Guide

Transactions & Isolation

ACID in practice, what each isolation level actually permits, and the locking behaviour behind the incident.

— min read Backend

All Or Nothing, Under Concurrency

A transaction groups statements so they succeed or fail together. That part is easy to understand. The hard part is what other transactions see while yours is running — which is what isolation levels define.
PropertyGuarantees
AtomicityAll statements commit, or none do
ConsistencyConstraints hold before and after
IsolationConcurrent transactions do not corrupt each other
DurabilityA committed write survives a crash

Isolation is the one with a dial on it, because perfect isolation means running transactions one at a time. Every level below that trades a specific anomaly for concurrency, and the job is knowing which anomaly you just accepted.

Isolation Levels

LevelDirty readNon-repeatable readPhantom
Read uncommittedPossiblePossiblePossible
Read committedNoPossiblePossible
Repeatable readNoNoPossible*
SerializableNoNoNo
AnomalyIs
Dirty readReading data another transaction has not committed
Non-repeatable readThe same row read twice returns different values
Phantom readThe same query returns a different set of rows
Lost updateTwo read-modify-writes, and one silently wins

Defaults differ and it matters: PostgreSQL and Oracle default to read committed, MySQL InnoDB to repeatable read. Code that assumes one behaviour and runs against the other produces bugs that only appear under concurrency.

*PostgreSQL's repeatable read uses snapshot isolation and does not exhibit classic phantoms, but it can still hit write skew — which is why genuinely conflicting invariants need serializable or explicit locking.

Optimistic & Pessimistic Locking

Two transactions read a balance of 100, both subtract 30, both write 70. One update is silently lost. Isolation alone does not prevent this at the usual levels — you have to choose a locking strategy.

-- pessimistic: take the lock up front, others wait
BEGIN;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
UPDATE accounts SET balance = balance - 30 WHERE id = 42;
COMMIT;

-- optimistic: no lock; the version check fails the loser, who retries
UPDATE accounts
   SET balance = 70, version = version + 1
 WHERE id = 42 AND version = 7;      -- 0 rows updated means someone beat you
PessimisticOptimistic
CostWaiting, and deadlock riskWasted work on conflict
Best whenContention is commonConflicts are rare
Client mustKeep the transaction shortHandle the retry

Where the operation is expressible as a single atomic statement — SET balance = balance - 30 — the database does it for you, and neither strategy is needed. Reach for the read-modify-write shape only when the new value genuinely depends on application logic.

Deadlocks & Long Transactions

A deadlock is two transactions each holding what the other needs. The database detects the cycle and kills one, so your application will see a deadlock error and must be able to retry it.

PracticeEffect
Lock rows in a consistent orderRemoves the cycle entirely — the real fix
Keep transactions shortLess time holding locks, fewer collisions
Never wait on a human or an API inside oneA transaction spanning a network call is an outage waiting
Retry deadlock errors with backoffThey are expected, not exceptional
Set a statement timeoutBounds the damage of a runaway query
A long-running transaction also blocks vacuum and log truncation, so a forgotten open transaction degrades the whole database — not only the rows it touched.

Interview Questions

What do isolation levels trade?

Concurrency against specific anomalies. Stricter levels prevent dirty, non-repeatable and phantom reads but reduce throughput; every level below serializable accepts a named anomaly.

Dirty, non-repeatable and phantom reads — the difference?

A dirty read sees uncommitted data; a non-repeatable read gets different values for the same row twice; a phantom read gets a different set of rows for the same query.

Why do defaults matter?

PostgreSQL defaults to read committed and MySQL InnoDB to repeatable read. Logic written against one and deployed on the other breaks only under concurrency, which makes it hard to reproduce.

Optimistic or pessimistic locking?

Pessimistic where contention is common — take the lock and make others wait. Optimistic where conflicts are rare — check a version on write and retry the loser.

How do you prevent deadlocks?

Acquire locks in a consistent order across the codebase, keep transactions short, and retry on the deadlock error, which is expected rather than exceptional.

Why are long transactions harmful beyond locking?

They hold locks for longer than the work needs and block vacuum and log truncation, degrading the whole database rather than just the affected rows.

Quick Quiz

1. Reading uncommitted data is a…
2. MySQL InnoDB defaults to…
3. Two read-modify-writes overwriting each other is a…
4. SELECT … FOR UPDATE is…
5. The real fix for deadlocks is…