Transactions and the lost update
Two clerks sell the same tee at the same moment and the stock only drops by one. Find out why BEGIN and COMMIT alone don't fix it, and the three fixes that do - each checked against a real PostgreSQL.
A transaction groups statements so they succeed or fail together. Move money between two accounts inside one, and you never see the money leave one account without arriving in the other. That’s the promise most people know.
There’s a quieter problem a transaction doesn’t solve on its own: two people changing the same row at the same time.
The lost update
The shop has 10 panda tees. Two clerks each sell one, half a second apart. Each clerk’s app does the obvious thing: read the stock, subtract one, and write the result back.
- Clerk A reads the stock: 10.
- Half a second later clerk B reads it too, before A has written anything. B also sees 10.
- A works out 10 - 1 and writes 9.
- B works out 10 - 1 from its own read and also writes 9, overwriting A's change. Two tees sold, and the stock dropped by one.
Does BEGIN … COMMIT fix it?
Run it and see. Every strategy below replays what PostgreSQL 17.6 actually did with two real psql sessions, recorded for this lesson, including the moment clerk B sat waiting.
Why the transaction alone didn’t help
PostgreSQL’s default isolation level is Read Committed: each statement sees the data committed before it began. Clerk B’s UPDATE found the row locked by A, so it waited for A to commit, exactly as the documentation describes. Then it carried on and wrote its 9. The database protected the row. It couldn’t know that B’s 9 was based on a number that was no longer true.
The three fixes all stop B from acting on a stale read:
| Fix | What changes | Cost |
|---|---|---|
SELECT ... FOR UPDATE |
B’s read waits until A commits, so B reads 9 | Clerks queue for the row |
SET stock = stock - 1 |
The database does the arithmetic on the current value; no stale read exists | Only works when the change is a simple calculation |
REPEATABLE READ |
B’s write is refused with a serialization error | The app must catch the error and retry |
Both clerks run BEGIN; SELECT stock; UPDATE stock = (what they read - 1); COMMIT at READ COMMITTED. What happens?
Read Committed makes the second updater wait for the first, then proceed. It protects the row, not the arithmetic your application did with a value it read earlier.
Which change removes the lost update with no extra locking statement in the app?
The database computes stock - 1 from the row's current committed value. The second update waits for the first and then applies to the new row, so both decrements count.
Everything above ran in your browser. Now do it on a real cluster withthe tilt platform. You need Docker, kind and Tilt, about 15 GB of free disk and 8 GB of spare RAM. Setup guide · Can my machine run it?
The gitops profile runs a CloudNativePG cluster called demo-pg. Open two terminals and connect both:
kubectl -n cnpg-system exec -it demo-pg-1 -- psql demoThen, in each terminal in turn (A first, then B, then back to A):
CREATE TABLE IF NOT EXISTS items (sku text PRIMARY KEY, stock int);
INSERT INTO items VALUES ('panda-tee', 10) ON CONFLICT (sku) DO UPDATE SET stock = 10; -- terminal A only
BEGIN;
SELECT stock FROM items WHERE sku = 'panda-tee'; -- A, then B: both see 10
UPDATE items SET stock = 9 WHERE sku = 'panda-tee'; -- A, then B: B hangs
COMMIT; -- in A: watch B's UPDATE finish
COMMIT; -- in B
SELECT stock FROM items WHERE sku = 'panda-tee'; -- 9Repeat with SELECT ... FOR UPDATE, and then with BEGIN ISOLATION LEVEL REPEATABLE READ;, and compare.
Sources
Missions and quizzes above earn their own XP. This marks the lesson done.