A shop has 10 of something in stock. Two customers buy one each at almost the same moment. Each purchase is a transaction that reads the stock, subtracts one in the application, and writes the result back.
After both, the stock should be 8.
Two sales, one stock count, and a decrement that disappears. No error and no warning. It’s the concurrency bug I’ve found most often in real code.
Scroll to advance. Steps 1–7 are the idea, steps 8–13 are how Postgres does it. Switch the level, and the way the query is written, at any step.
{{ warmFb }}
A shop has 10 of something in stock. Two customers buy one each at almost the same moment. Each purchase is a transaction that reads the stock, subtracts one in the application, and writes the result back.
After both, the stock should be 8.
A reads the stock and gets 10. B reads it a moment later and also gets 10. Nothing has been written yet, so both are right.
A’s code computes 10 − 1 and writes 9. A commits.
{{ fb3 }}
B’s code also computed 10 − 1, so it writes 9 and commits. The stock is 9. Two items left the shelf and the count only went down by one.
A’s update was overwritten without a trace. That’s a lost update.
From the database’s point of view, B did something perfectly normal: it wrote the number 9 to a row nobody else was using at that moment. It has no way of knowing that 9 came from a value B read earlier, which had since gone stale.
The mistake lives in the gap between the read and the write, and that gap is in your application.
{{ fb5 }}
The simplest fix is to stop reading first: UPDATE stock SET n = n − 1. The subtraction now happens inside the database, on whatever the value is when the update runs. B’s update finds A’s 9 and writes 8.
Try the three buttons above. How the query is written matters more here than the level.
Go back to read-then-write, under REPEATABLE READ. Postgres notices that B is about to overwrite a row that changed after B’s snapshot was taken. Instead of silently losing A’s work, it aborts B with a serialization error.
B runs again, reads 9, and writes 8. No data is lost, but your code needs a retry loop.
You can stop here and you’ll know how to avoid lost updates. The next six steps are what actually happens when two writers meet on one row.
Every UPDATE locks the row it changes until its transaction ends. Postgres keeps that lock in the row itself: the old version’s xmax holds the id of the transaction changing it.
In this order B’s update arrives while A is still open, so B waits. Readers ignore this lock completely. Only other writers queue behind it.
{{ fb8 }}
When A commits, B wakes up. Under READ COMMITTED, Postgres doesn’t continue with the version B found at first. It fetches the newest committed version, checks the WHERE clause again, and applies the update to that.
That re-check is why n − 1 gives 8, and why a condition like WHERE n > 0 is tested against the real current stock.
In the Postgres source this step is called EvalPlanQual.
Under REPEATABLE READ the wait ends differently. If A commits, B gets the serialization error, because the newest version isn’t one B’s snapshot can see.
If A had rolled back instead, B would carry on as if nothing happened. Whether B fails depends on how A finished, not on anything B did.
Sometimes the decision needs application logic, like checking a credit limit before selling. Then lock the row as you read it: SELECT … FOR UPDATE. B’s read waits at the first line until A is done, then reads 9.
It’s correct under READ COMMITTED. The cost is that B sits idle while A holds the lock, including any slow work A does before committing.
Under REPEATABLE READ, the locked read itself fails with the serialization error.
Locking reads bring a new way to fail. If A locks row 1 and then row 2, while B locks row 2 and then row 1, each ends up waiting for the other forever.
Postgres checks for this after deadlock_timeout, one second by default, and aborts one of them. Lock rows in a consistent order, for example by id, and it can’t happen.
My rule of thumb. If the change fits in one UPDATE, write it that way. If the application has to decide, use FOR UPDATE and keep the transaction short. If many rows are involved and conflicts are rare, use REPEATABLE READ and retry on 40001.
A version column, UPDATE … WHERE version = 7, is the same idea done by hand. It also works across two HTTP requests, where no transaction can stay open.
Every idea in the series, and every step where it appears. To review, pick a term, jump to a step, and come back.
| Anomaly | Read committed | Repeatable read | Serializable | What stops it |
|---|---|---|---|---|
| Dirty read | prevented | prevented | prevented | Uncommitted versions are invisible · part 2, step 8 |
| Read skew | allowed | prevented | prevented | One snapshot per transaction · part 3, step 7 |
| Lost update (read, then write)this note | allowed | one fails · 40001 | one fails · 40001 | Refusing to overwrite a newer row · part 4, step 7 |
| Write skew | allowed | allowed | one fails · 40001 | Tracking what was read (SSI) · part 5, step 6 |
| Phantom read | allowed | prevented | prevented | One snapshot per transaction · part 6, step 5 |
| Write skew on new rows | allowed | allowed | one fails · 40001 | Predicate locks · part 6, step 9 |
{{ q.q }}
{{ q.fb }}
{{ q.model }}