Transactions & Locking

ACID transactions, isolation levels, deadlocks, optimistic vs pessimistic locking, and the concurrency bugs that plague real systems.

transactionsacidlockingdeadlockisolationconcurrency

Starting from Zero — A Physical Intuition

Before discussing ACID and locks, let's visualize transactions through physical analogies:

  • The Ink Ledger (ACID transactions): Imagine a paper bank ledger. Transferring $100 from Alice to Bob requires two steps: subtracting 100 from Alice's line, and adding 100 to Bob's line.
    • Atomicity: You must write both lines in permanent ink. If you drop your pen mid-way (a crash), you cannot leave the ledger with only the subtraction written. You must cross out the scratch (rollback) so it's as if nothing happened.
    • Durability: Once both lines are written and stamped, they cannot disappear—even if the bank building catches fire, the physical ledger is locked in a fireproof safe (disk storage).
  • The Locked Cabinet vs The Version Check (Locking):
    • Pessimistic Locking: You walk up to a client's filing cabinet and put a padlock on it (SELECT FOR UPDATE). Nobody else can look inside or make changes until you finish your work and unlock it (COMMIT).
    • Optimistic Locking: You leave the client folder unlocked on a table. Before you edit it, you note down the stamped version number on the cover (e.g. Version 5). You copy the data to your notepad, do your thinking, and write your updates. When you return to save your changes, you check if the cover is still stamped Version 5. If it is, you write your changes and stamp it Version 6. If it has already been stamped Version 6 by someone else, you throw away your notepad draft and start over.

Why Transactions Exist

Without transactions, concurrent database operations produce corrupted data:

The bank transfer problem — without transactions:

Thread A: Read Alice's balance: $500
Thread B: Read Alice's balance: $500
Thread A: Alice - $100 → Write $400
Thread B: Alice - $100 → Write $400  ← Overwrites A's write! Alice loses $100.

A transaction groups multiple operations into an atomic unit: all succeed or all fail, no half-states.

BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- debit Alice
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- credit Bob
  -- If anything fails here, ROLLBACK undoes both updates
COMMIT;

The ACID Properties (Deep Dive)

Atomicity

All operations in a transaction complete, or none do. PostgreSQL uses a Write-Ahead Log (WAL):

  1. Before changing data on disk, write the change to the WAL
  2. On crash: replay WAL to redo committed transactions, undo uncommitted ones
  3. Application never sees partial state

Consistency

The database moves from one valid state to another. All constraints are enforced atomically:

BEGIN;
  INSERT INTO orders (user_id, total) VALUES (1, 150.00);
  -- FK check: does user_id=1 exist? If not, ROLLBACK entire transaction
  -- CHECK constraint: total >= 0? If not, ROLLBACK
  -- UNIQUE constraint: duplicate order_number? ROLLBACK
COMMIT;

Isolation

Concurrent transactions behave as if they ran sequentially. The level of isolation is configurable — more isolation = more safety but less throughput.

Durability

Once committed, data survives crashes. PostgreSQL achieves this via:

  • WAL written to disk before COMMIT returns
  • fsync() ensures WAL is truly flushed (not just OS buffer)
  • Checkpoint process periodically writes WAL changes to actual data files

Concurrency Anomalies

Understanding what can go wrong at each isolation level:

Dirty Read

Transaction A starts, updates row X (not committed)
Transaction B reads row X → gets A's uncommitted value
Transaction A rolls back
Transaction B now has data that never existed

Prevented at: Read Committed and above.

Non-Repeatable Read

Transaction A reads row X → gets value 100
Transaction B commits: UPDATE row X to 200
Transaction A reads row X again → gets 200 (different!)

Prevented at: Repeatable Read and above.

Phantom Read

Transaction A queries: SELECT * FROM orders WHERE status = 'pending'
Transaction B commits: INSERT INTO orders (status) VALUES ('pending')
Transaction A runs the same query → gets additional row (a "phantom")

Prevented at: Serializable only (PostgreSQL's Repeatable Read also prevents phantoms via MVCC).

Lost Update

Transaction A reads balance: 100
Transaction B reads balance: 100
Transaction A writes: balance + 50 = 150
Transaction B writes: balance + 30 = 130  ← A's update is lost!

Prevented by: proper locking or optimistic concurrency control.

Pick an anomaly and an isolation level and watch two transactions interleave — the critical read lights green when the level prevents it, rose when it slips through. Run Non-repeatable at Read Committed (it slips through), then raise it to Repeatable Read and watch it vanish:

Transaction isolation — anomalies vs levelstime space
T1
begin
read x → 10
read x
T2
begin
x := 20
commit
Read Committed

1/8Two transactions interleave at Read Committed. Initial state: x = 10. Watch what T1 sees at its critical read.

isolation = Read Committed
anomaly
isolation level

Isolation Levels in Practice

-- Set isolation level for the current transaction
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- PostgreSQL default
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- Or set session default
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;

Production guidance:

  • Read Committed (PostgreSQL default): sufficient for most CRUD operations. Prevents dirty reads.
  • Repeatable Read: use when a transaction reads the same data multiple times and must see consistent values. Financial reporting, generating documents.
  • Serializable: use when correctness requires full isolation. Complex financial operations, inventory management. Has overhead — serialization errors possible, requires retry logic.

Locking — Two Strategies

Pessimistic Locking — "Lock before you work"

Acquire a lock before reading/writing. Other transactions must wait.

-- SELECT FOR UPDATE — locks selected rows until transaction ends
BEGIN;
  SELECT * FROM orders WHERE id = 42 FOR UPDATE;
  -- Other transactions trying to touch order 42 will BLOCK here
  UPDATE orders SET status = 'processing' WHERE id = 42;
COMMIT;
-- Lock released at COMMIT

-- SELECT FOR SHARE — allows concurrent reads, blocks writes
BEGIN;
  SELECT balance FROM accounts WHERE id = 1 FOR SHARE;
  -- Other transactions can read balance concurrently
  -- But can't UPDATE balance until we COMMIT
COMMIT;

-- SKIP LOCKED — skip rows already locked (great for job queues)
BEGIN;
  SELECT * FROM job_queue WHERE status = 'pending'
  ORDER BY priority DESC
  LIMIT 1
  FOR UPDATE SKIP LOCKED;  -- multiple workers take different jobs, no waiting
  UPDATE job_queue SET status = 'processing', worker_id = $1 WHERE id = ...;
COMMIT;

Optimistic Locking — "Work freely, check for conflicts at commit"

No locks during work. At commit, verify nobody else changed the data.

-- Version-based optimistic locking
-- Table has a `version` column (integer, starts at 1)

-- 1. Read with version
SELECT id, balance, version FROM accounts WHERE id = 1;
-- Returns: id=1, balance=500, version=3

-- 2. Do work (in application layer, no DB lock held)

-- 3. Update only if version hasn't changed
UPDATE accounts
SET balance = 450, version = version + 1
WHERE id = 1 AND version = 3;
-- Returns rowcount=1: success → proceed
-- Returns rowcount=0: someone else updated first → retry the whole operation

-- In ORM (TypeORM example):
@Column()
@VersionColumn()
version: number;
// TypeORM automatically adds WHERE id=? AND version=? to every UPDATE

Choose pessimistic when: contention is high, retries are expensive, operations are long. Choose optimistic when: contention is low, retries are cheap, reads are far more common than writes.


Table-Level vs Row-Level Locks

-- Row-level locks (default for DML — only locks specific rows)
UPDATE users SET name = 'Alice' WHERE id = 1;  -- only locks row id=1

-- Table-level locks (explicit, blocks all access to the table)
LOCK TABLE users IN EXCLUSIVE MODE;
-- Rarely needed — DDL statements (ALTER TABLE) do this automatically

-- Advisory locks — application-level named locks (not tied to data)
SELECT pg_advisory_lock(12345);   -- acquire lock named "12345"
-- ... critical section ...
SELECT pg_advisory_unlock(12345); -- release

-- Use case: prevent two processes from running the same job simultaneously

Deadlocks

A deadlock occurs when two transactions each hold a lock the other needs:

Transaction A: Lock order 1, then try to lock order 2 → WAITING for B
Transaction B: Lock order 2, then try to lock order 1 → WAITING for A
Both wait forever → DEADLOCK

PostgreSQL automatically detects deadlocks and aborts one transaction (the "victim") with error ERROR: deadlock detected. Your application must catch this and retry.

Python
import psycopg2
from time import sleep
import random

def transfer_with_retry(from_id, to_id, amount, max_retries=3):
    for attempt in range(max_retries):
        try:
            with connection.cursor() as cur:
                cur.execute("BEGIN")
                # Lock in consistent order (always lower ID first) — prevents deadlocks
                low, high = min(from_id, to_id), max(from_id, to_id)
                cur.execute("SELECT balance FROM accounts WHERE id IN (%s, %s) FOR UPDATE",
                            (low, high))
                cur.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s",
                            (amount, from_id))
                cur.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s",
                            (amount, to_id))
                cur.execute("COMMIT")
                return  # success
        except psycopg2.errors.DeadlockDetected:
            cur.execute("ROLLBACK")
            if attempt == max_retries - 1:
                raise
            sleep(random.uniform(0.1, 0.5))  # jitter before retry

Deadlock prevention strategies:

  1. Consistent lock ordering — always acquire locks in the same order (e.g., by ID ascending)
  2. Short transactions — hold locks for the minimum time
  3. Lock all resources upfront — acquire all needed locks at transaction start
  4. Retry logic — deadlocks are expected; handle them gracefully

Two-Phase Locking (2PL)

The theoretical framework behind pessimistic locking:

Growing phase:   Transaction acquires locks, never releases
Shrinking phase: Transaction releases locks, never acquires new ones

Strict 2PL:     All locks released only at COMMIT/ROLLBACK
                (this is what PostgreSQL actually uses)

Strict 2PL guarantees serializability — transactions appear to have run one at a time.


MVCC vs Locking

PostgreSQL combines MVCC with locking:

READS  → MVCC (see a snapshot, never blocked by writers)
WRITES → Lock the specific rows being modified

This means:
SELECT never waits for UPDATE on the same row
UPDATE waits if another UPDATE already locks the row
-- This query NEVER blocks (MVCC snapshot):
SELECT * FROM orders;  -- sees snapshot from transaction start

-- This query BLOCKS if another transaction has a lock on id=42:
UPDATE orders SET status = 'done' WHERE id = 42;

Savepoints

BEGIN;
  INSERT INTO users (email) VALUES ('alice@example.com');

  SAVEPOINT sp_user;

  INSERT INTO profiles (user_id, bio) VALUES (LASTVAL(), 'About me');

  -- Something goes wrong with profile creation:
  ROLLBACK TO SAVEPOINT sp_user;  -- undo profile insert, keep user insert

  -- Try again with different data:
  INSERT INTO profiles (user_id, bio) VALUES (LASTVAL(), 'Default bio');

COMMIT;  -- user + new profile both committed

Common Interview Questions

Design drills

Concurrency is where databases get hard. Whiteboard each before revealing the checklist.

Design drills: Transactions & concurrency0/5 done

Whiteboard each one out loud for 5–10 minutes before you reveal what a strong answer covers — the gap between your sketch and the checklist is your study list. Progress is saved on this device.

Warm-up

Two users withdraw from the same account at the same time. Show the lost-update bug, then the one-line fix.

Core

Name the lowest isolation level that prevents each: dirty read, non-repeatable read, phantom.

Core

Pessimistic (SELECT FOR UPDATE) vs optimistic (version column) locking — when do you choose each?

Stretch

Two transactions deadlock. What does the database do, and how do you prevent it?

Stretch

A multi-step operation spans two services, so you can't wrap it in one DB transaction. Now what?

Think it through like the interview

Don't just list isolation levels — trace how concurrency anomalies interleave in real database timelines.

Think it through: Transaction Isolation AnomaliesDatabase Concurrency0/3 stages

PROBLEMA credit system runs transactions concurrently. Tracing raw read/write timestamps, derive how the database identifies a Dirty Read, a Non-Repeatable Read, and a Phantom Read.

  1. 1

    Dirty Read (Read Uncommitted)

    Explain the timeline where Transaction A updates balance to $600, Transaction B reads the balance as $600, and Transaction A then aborts (Rollback). What is the anomaly?

  2. 2

    Non-Repeatable Read (Read Committed)

    Under the Read Committed level, Transaction B queries the account balance and sees $500. Transaction A updates the balance to $400 and COMMITS. Transaction B runs the exact same query in the same transaction and gets $400. Why does this occur?

    unlocks after the stage above
  3. 3

    Phantom Read (Repeatable Read vs Serializable)

    How does a Phantom Read differ from a Non-Repeatable Read when querying database tables?

    unlocks after the stage above

Interactive Quiz

Check yourself0/3 answered

1.

2.

3.


Practice

  1. Lost Update: Simulate the lost update problem — two concurrent Python threads both read a counter, increment, and write back. Show the bug, then fix it with FOR UPDATE locking.
  2. Optimistic Locking: Implement a version-based optimistic lock in Node.js — show a conflict, handle it with a retry, and test with two concurrent requests.
  3. Deadlock: Deliberately create a deadlock scenario with two transactions that lock resources in opposite orders. Capture the error and implement retry logic.
  4. SKIP LOCKED Queue: Implement a job queue with PostgreSQL where multiple worker processes can pull jobs concurrently without conflicts, using SELECT FOR UPDATE SKIP LOCKED.

This covers Level 9 — Databases. Next: Cloud & DevOps for Level 10.