SQL
Advanced 12 min read

Transactions & isolation levels

In one line

A transaction groups several statements into one all-or-nothing unit (ACID), and its isolation level decides how much concurrent transactions can see of each other's unfinished or newly committed work.

What it is

A transaction starts with BEGIN, runs statements, and ends with COMMIT (make every change permanent) or ROLLBACK (undo all of them). ACID describes the guarantees: Atomicity (all or nothing), Consistency (constraints hold before and after), Isolation (concurrent transactions do not interfere in forbidden ways) and Durability (once committed, the change survives a crash, thanks to a write-ahead log: WAL in PostgreSQL, the redo log in InnoDB).

Isolation is a trade-off between correctness and concurrency. The SQL standard defines four levels by which anomalies they allow: Read Uncommitted (dirty reads possible), Read Committed (no dirty reads, but a re-read can change: non-repeatable reads), Repeatable Read (rows you read do not change, but new matching rows can appear: phantoms) and Serializable (the result is as if transactions ran one at a time).

Real engines differ from the textbook. PostgreSQL defaults to Read Committed; its Read Uncommitted behaves like Read Committed, its Repeatable Read is snapshot isolation that also prevents phantoms, and its Serializable uses SSI, which aborts a transaction with a serialization failure rather than allow an anomaly. MySQL InnoDB defaults to Repeatable Read, using a consistent snapshot for plain SELECTs and next-key locks for locking reads and writes.

Why interviewers ask

Any system that moves money, books seats or decrements stock must stay correct under concurrent requests. Interviewers test whether you know the anomalies by name, which level prevents which, the defaults in PostgreSQL and MySQL, and practical fixes for lost updates such as atomic updates, SELECT ... FOR UPDATE, optimistic locking and retries.

How it works

  1. 1Most modern engines use MVCC (multi-version concurrency control): an update writes a new version of the row instead of overwriting it, so readers see a consistent snapshot without blocking writers, and writers do not block readers.
  2. 2At Read Committed, each statement sees a fresh snapshot of data committed before that statement began. Two SELECTs in the same transaction can therefore see different values.
  3. 3At Repeatable Read (PostgreSQL) or with consistent reads in InnoDB, the snapshot is taken at the first read of the transaction and reused, so plain reads never change during it.
  4. 4Writes to the same row are still serialised with row locks: a second transaction updating a row that another transaction has updated but not committed waits. In PostgreSQL Read Committed, after the first commits, the waiting UPDATE re-checks its WHERE clause against the new row version; at Repeatable Read or Serializable it fails with a serialization error instead.
  5. 5SELECT ... FOR UPDATE locks the rows it reads so no one else can change them until you commit; FOR SHARE allows other readers to lock too but blocks writers.
  6. 6If two transactions each wait for a lock the other holds, the database detects the deadlock and aborts one of them. Applications must catch serialization failures and deadlocks (SQLSTATE 40001 and 40P01 in PostgreSQL) and retry the whole transaction.

Worked example · A lost update and how to prevent it

  1. accounts(id, balance): (1, 100). Two requests, A and B, each try to withdraw 80 at the same time at Read Committed. The app code reads, checks, then writes.
  2. A: SELECT balance FROM accounts WHERE id = 1 returns 100. B: the same SELECT also returns 100.
  3. A checks 100 >= 80 and runs UPDATE accounts SET balance = 20 WHERE id = 1, then COMMIT.
  4. B also checks 100 >= 80 and runs UPDATE accounts SET balance = 20 WHERE id = 1, then COMMIT. Result: balance 20, but 160 was paid out. A's update was lost.
  5. Fix 1, atomic conditional update: UPDATE accounts SET balance = balance - 80 WHERE id = 1 AND balance >= 80. A updates the row to 20 and holds the row lock. B waits; when A commits, B re-checks the WHERE against balance 20, finds it false, and updates 0 rows. The app sees 0 rows affected and rejects B.
  6. Fix 2, pessimistic locking: SELECT balance ... FOR UPDATE in both. B's SELECT waits until A commits, then reads 20 and refuses.
  7. Fix 3, optimistic locking: add a version column and UPDATE ... SET balance = 20, version = version + 1 WHERE id = 1 AND version = 7. B's update matches 0 rows because A already bumped version to 8, so B retries with fresh data.

Picture it

A transaction is a shopping cart at checkout: either the whole cart is paid for or nothing is. Isolation levels are like how much you can see of other shoppers' carts. At Read Committed you only see what others have actually paid for, but the shelf can change while you shop. At Serializable it is as if every shopper went through the store one at a time.

Reference code

-- accounts(id, balance): transfer 80 from account 1 to account 2
BEGIN;

-- Lock both rows in a fixed order (lowest id first) to avoid deadlocks
SELECT id, balance
FROM accounts
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;

-- Debit only if funds are sufficient; app checks that 1 row was updated
UPDATE accounts SET balance = balance - 80
WHERE id = 1 AND balance >= 80;

-- If 0 rows were updated: ROLLBACK and report insufficient funds
UPDATE accounts SET balance = balance + 80
WHERE id = 2;

COMMIT;

-- Stricter isolation for one transaction (PostgreSQL / standard syntax):
-- BEGIN ISOLATION LEVEL SERIALIZABLE;   -- retry on SQLSTATE 40001
-- MySQL: SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; START TRANSACTION;

Starting from balances (1, 100) and (2, 0), one transfer leaves (1, 20) and (2, 80). A concurrent second transfer waits on the FOR UPDATE lock, then sees balance 20, its debit updates 0 rows, and the app rolls it back. Either both updates happen or neither does.

Key patterns

Atomic conditional update

When: Decrement a balance or stock only if enough remains

How: UPDATE ... SET x = x - n WHERE id = ? AND x >= n, then check rows affected. One statement, no read-then-write race.

Pessimistic locking

When: High contention on a few rows, or several reads must stay valid until commit

How: SELECT ... FOR UPDATE inside the transaction. Lock rows in a consistent order (for example by id) to avoid deadlocks.

Optimistic locking

When: Conflicts are rare and you do not want to hold locks (for example across a user's edit form)

How: Store a version column; UPDATE ... WHERE id = ? AND version = ?; if 0 rows change, reload and retry or report a conflict.

Serializable plus retry

When: Complex invariants across many rows (write skew), like 'at least one doctor on call'

How: Run at SERIALIZABLE and wrap the transaction in a retry loop for SQLSTATE 40001. Simpler to reason about than hand-placed locks.

Constraints as the last line of defence

When: Any uniqueness or validity rule

How: UNIQUE, CHECK, FOREIGN KEY and exclusion constraints are enforced by the database for every transaction, regardless of isolation level.

Real-world use

Payments and wallets

Payment systems run each transfer as one transaction (debit one account, credit another) so money is never created or lost, and use idempotency keys plus retries on serialization failures.

Ticketing and seat booking

Booking sites lock the chosen seat rows with SELECT ... FOR UPDATE (often with SKIP LOCKED or NOWAIT) so two buyers cannot pay for the same seat.

Job queues in PostgreSQL

PostgreSQL-backed queues such as GoodJob and Que (Ruby) and Oban (Elixir) pull work with SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1, so many workers grab different jobs without blocking each other.

Inventory in e-commerce

Stock is decremented with UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0, an atomic check-and-set that prevents overselling during flash sales.

In a project

A doctor-appointment app sometimes double-books a slot when two patients click 'Book' at the same moment. The booking code reads free slots, then inserts an appointment.

  1. Reproduce the race with two concurrent sessions in psql to confirm it is a check-then-act race at Read Committed.
  2. Add a database-level guarantee first: a unique constraint on (doctor_id, slot_start) for active appointments (a partial unique index WHERE status = 'booked' in PostgreSQL), so the second insert fails no matter what the app does.
  3. Wrap the booking in a transaction that locks the slot row with SELECT ... FOR UPDATE, then marks it booked and inserts the appointment.
  4. Catch unique-violation, serialization and deadlock errors, and return 'slot just taken' or retry up to 3 times with a short random backoff.
  5. Keep the transaction short: do not call the payment provider or send emails while holding the lock; do those after commit.

Impact: Double bookings dropped to zero, and the constraint means any future code path is protected too. Lock waits stay in the low milliseconds because transactions only cover the database work.

Trade-offs

Use it when

  • Read Committed (PostgreSQL default): most web workloads, combined with atomic updates and constraints
  • Repeatable Read: reports or exports that must see one consistent snapshot across many queries
  • Serializable: complex multi-row invariants where you prefer automatic conflict detection plus retries
  • Explicit FOR UPDATE locks: short, high-contention critical sections like seat booking

Avoid it when

  • Long transactions that wait on users or external APIs while holding locks; they block others and, in PostgreSQL, hold back VACUUM
  • Serializable without a retry loop: serialization failures are normal, not bugs
  • Read Uncommitted for anything that matters: dirty reads can show data that is later rolled back
  • Read-then-write logic in application code without locks, constraints or version checks

Cost: Higher isolation means more locking or more aborted and retried transactions, which lowers throughput under contention. MVCC keeps reads non-blocking but leaves old row versions to clean up (VACUUM in PostgreSQL, purge in InnoDB).

Common mistakes

  • Mixing up the anomalies: dirty read = seeing uncommitted data; non-repeatable read = the same row changes between two reads; phantom = a re-run query returns new rows.
  • Assuming the default is Serializable. PostgreSQL defaults to Read Committed and MySQL InnoDB to Repeatable Read.
  • Believing a transaction alone prevents lost updates. At Read Committed, read-then-write in app code still races; use atomic updates, FOR UPDATE or version checks.
  • Not retrying on serialization failures or deadlocks, so users see random errors under load.
  • Doing slow work (HTTP calls, emails) inside a transaction, holding locks and connections for seconds.

Interview questions

Quick revision

  • ACID: Atomicity, Consistency, Isolation, Durability (WAL / redo log).
  • Read Uncommitted < Read Committed < Repeatable Read < Serializable.
  • Dirty read (RC prevents), non-repeatable read (RR prevents), phantom (Serializable prevents; PostgreSQL RR too).
  • Defaults: PostgreSQL Read Committed; MySQL InnoDB Repeatable Read.
  • Lost update fix: UPDATE x = x - n WHERE x >= n, FOR UPDATE, or version column.
  • Serializable can abort with SQLSTATE 40001: always retry the whole transaction.
  • Deadlock: DB aborts one victim; lock rows in a consistent order.
  • Keep transactions short; no network calls inside.

Check yourself

1. What are the default isolation levels of PostgreSQL and MySQL InnoDB?

2. Inside one transaction you read a row twice and get different values because another transaction committed an update in between. What is this called?

3. A PostgreSQL transaction at SERIALIZABLE fails with a serialization failure (SQLSTATE 40001). What should the application do?

4. Which ACID property guarantees a committed transaction survives a power failure right after COMMIT?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free