1. BEGIN, COMMIT & ROLLBACK
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.
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
COMMITreturns 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.
-- 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
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
Reproduce and fix a real race condition
Build a small inventory table, deliberately trigger the race from this week, then fix it.
Requirements:
- A
productstable with astockcolumn, seeded with one product atstock = 1. - Using two separate
psqlsessions (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. - 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. - Confirm the fixed version correctly rejects the second purchase attempt (stock already 0) rather than allowing it.
- Write a short explanation (a comment) of why wrapping the original code in
BEGIN/COMMITalone — withoutFOR UPDATE— was not enough to prevent the race.
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?
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?
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?
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?
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.