Week 7: Transactions & ACID Guarantees

Every query so far has run alone. Real applications run many queries at once, often touching the same rows — a bank transfer that must debit one account and credit another as a single, indivisible unit, or two customers trying to buy the last item in stock at the same moment. This week is what keeps that correct.

Module 7 of 10 Week 7 of 10 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Group multiple statements into an atomic unit with BEGIN/COMMIT/ROLLBACK
  • Explain what each letter of ACID actually guarantees
  • Describe a race condition that isolation levels exist to prevent
  • Fix a real concurrency bug using a transaction and row locking

1. BEGIN, COMMIT & ROLLBACK

a bank transfer — must succeed or fail as one unit
BEGIN;

UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- debit
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- credit

COMMIT; -- both changes become permanent together, or...
-- ROLLBACK; -- ...neither does, if something went wrong

Without wrapping both statements in a transaction, a crash or error between the two UPDATEs leaves $100 debited from account 1 with nowhere credited — money genuinely vanishes. BEGIN starts a transaction; COMMIT makes every change within it permanent, all at once; ROLLBACK discards every change within it, as if none of it ever happened.

rolling back automatically on error
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accoutns SET balance = balance + 100 WHERE id = 2; -- typo — errors out
-- Postgres aborts the transaction here; the first UPDATE is NOT committed
COMMIT; -- has nothing to commit — the whole transaction already failed

2. What ACID Actually Guarantees

  • Atomicity — a transaction's statements succeed or fail together, exactly as shown above; there's no partial state.
  • Consistency — a transaction can only move the database from one valid state to another; every constraint from Week 6 still holds after it commits.
  • Isolation — concurrent transactions don't see each other's uncommitted changes (the exact degree is configurable — next section).
  • Durability — once COMMIT returns successfully, the change survives a crash immediately afterward; it's written to disk, not just held in memory.

These four properties, together, are the actual reason "just use a real database" is meaningful advice — a hand-rolled file-based storage system has to rebuild every one of these guarantees from scratch, and getting even one subtly wrong reintroduces exactly the kind of bug this week exists to prevent.

3. Isolation Levels & Race Conditions

Two transactions running at the same time, touching the same rows, can interact in ways a single transaction never would — a race condition.

a classic race — two customers buying the last item
-- Transaction A                          -- Transaction B
BEGIN;                                     BEGIN;
SELECT stock FROM products WHERE id = 1;   SELECT stock FROM products WHERE id = 1;
-- sees stock = 1                          -- also sees stock = 1
UPDATE products SET stock = 0 WHERE id=1;
COMMIT;
                                            UPDATE products SET stock = -1 WHERE id=1; -- !
                                            COMMIT;

Both transactions read stock = 1 before either committed — without protection, both proceed as if they alone are buying the last item, and stock goes negative. Postgres's default isolation level, Read Committed, does not prevent this specific race on its own; the fix (next section) is explicit locking, not just wrapping the statements in a transaction.

4. Fixing a Real Concurrency Bug

SELECT ... FOR UPDATE — locking the row until this transaction commits
BEGIN;

SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- locks this row
-- any other transaction's FOR UPDATE on the same row now blocks and waits

-- application code checks: is stock > 0?
UPDATE products SET stock = stock - 1 WHERE id = 1;

COMMIT; -- releases the lock; the waiting transaction proceeds and re-reads fresh data

FOR UPDATE makes the second transaction genuinely wait for the first to finish, rather than both reading the same stale value — it re-reads stock only after acquiring the lock, at which point it correctly sees 0 and can reject the purchase instead of driving stock negative. This is the standard, correct pattern for "check then act" logic on shared, contended rows.

5. Hands-on Exercise

Hands-on

Reproduce and fix a real race condition

Build a small inventory table, deliberately trigger the race from this week, then fix it.

Requirements:

  1. A products table with a stock column, seeded with one product at stock = 1.
  2. Using two separate psql sessions (or terminal tabs) side by side, manually reproduce the race condition from this week — both sessions BEGIN, both SELECT the stock, then both UPDATE and COMMIT — and confirm stock ends up at -1 or otherwise wrong.
  3. Rewrite the purchase logic using SELECT ... FOR UPDATE, and repeat the same two-session test, confirming the second session now waits until the first commits.
  4. Confirm the fixed version correctly rejects the second purchase attempt (stock already 0) rather than allowing it.
  5. Write a short explanation (a comment) of why wrapping the original code in BEGIN/COMMIT alone — without FOR UPDATE — was not enough to prevent the race.
Hint

If you're not sure how to run two sessions concurrently to actually observe the race, open two separate psql connections to the same database (two terminal windows both running psql learn_sql) and manually interleave the commands — type BEGIN in both first, then the SELECT in both, then the UPDATE and COMMIT in one, then the other — to force the exact interleaving that produces the bug.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What real, concrete problem does wrapping the bank transfer in a transaction solve that running the two UPDATEs separately does not?

It guarantees atomicity — if anything fails between the debit and the credit, the entire transaction rolls back and neither change takes effect, so the two accounts never end up in an inconsistent state where money was debited but never credited. Running them as two separate, unwrapped statements leaves exactly that inconsistent state possible if a crash or error happens between them.

Q2

What does the "D" in ACID (Durability) specifically guarantee, and why does it matter separately from Atomicity?

Durability guarantees that once COMMIT returns successfully, the change is written to disk and survives even an immediate crash — it's specifically about surviving a failure *after* a successful commit. Atomicity is about what happens *during* a transaction (all-or-nothing); a database could satisfy Atomicity perfectly while still losing a committed change to a crash if it lacked Durability, which is why they're distinct guarantees.

Q3

Why does Postgres's default Read Committed isolation level not prevent the two-customers-buying-the-last-item race on its own?

Under Read Committed, each statement sees a fresh snapshot of committed data, but nothing stops two transactions from both reading the same stock value before either has committed its own update — neither transaction is aware of the other's in-progress work at the moment it reads. Preventing the race requires an explicit mechanism like row locking (FOR UPDATE), not just isolation level defaults or a bare transaction wrapper.

Q4

Why does SELECT ... FOR UPDATE fix the race where a plain SELECT inside the same transaction would not?

FOR UPDATE acquires a lock on the selected row that's held until the transaction commits or rolls back — a second transaction attempting its own FOR UPDATE on that same row is forced to wait until the lock is released, at which point it re-reads the row and sees the now-updated value. A plain SELECT takes no lock at all, so two transactions can both read the same stale value with nothing preventing either from proceeding as if it were the only one.