The lost update · Aleksei Zhynguel
Interactive noteTransaction isolation · part 4 of 7Sep 2026 · 17 minPostgres 16

The lost update

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.

{{ outShort }}
{{ aName }} t {{ bName }}
{{ e.seq }}
{{ e.t }}{{ e.s }}
Database · committed
{{ r.name }}{{ r.val }}
{{ v.t }}{{ v.s }}
{{ pnote }}
{{ outLabel }} {{ outVal }}
{{ warmFrom }} {{ warmQ }}

{{ warmFb }}

01 · Ten in stock

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.

02 · Both read

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.

03 · A writes

A’s code computes 10 − 1 and writes 9. A commits.

04 · B writes
Predict first · the diagram shows ? {{ aq3 }}
The explanation opens when you answer.

{{ 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.

05 · Why nothing complained

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.

06 · Let the database do the math
Predict first · the diagram shows ? {{ aq5 }}
The explanation opens when you answer.

{{ 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.

07 · Or let the level catch it

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.

Deeper

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.

08 · Row locks

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.

09 · What happens after the wait
Predict first · the diagram shows ? {{ aq8 }}
The explanation opens when you answer.

{{ 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.

10 · The same wait, under REPEATABLE READ

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.

11 · Locking the read

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.

12 · Deadlocks

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.

13 · Choosing

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.

Connections

Every idea in the series, and every step where it appears. To review, pick a term, jump to a step, and come back.

{{ kCount }}
{{ ocTerm }}

{{ ocDef }}

Related
  1. {{ r.where }}{{ r.title }}
The whole series in one table
AnomalyRead committedRepeatable readSerializableWhat stops it
Dirty readpreventedpreventedpreventedUncommitted versions are invisible · part 2, step 8
Read skewallowedpreventedpreventedOne snapshot per transaction · part 3, step 7
Lost update (read, then write)this noteallowedone fails · 40001one fails · 40001Refusing to overwrite a newer row · part 4, step 7
Write skewallowedallowedone fails · 40001Tracking what was read (SSI) · part 5, step 6
Phantom readallowedpreventedpreventedOne snapshot per transaction · part 6, step 5
Write skew on new rowsallowedallowedone fails · 40001Predicate locks · part 6, step 9
Postgres 16 behavior, as described in chapter 13.2 of its documentation. The SQL standard allows more than this at each level.
Check yourself{{ score }}
{{ q.n }}

{{ q.q }}

{{ q.fb }}

{{ q.model }}

Three things to keep
n = n − 1Do the arithmetic in SQL and there’s nothing to lose.
FOR UPDATELock what you read when the application has to decide. Keep the transaction short.
40001Under REPEATABLE READ a lost update becomes an error. Retry the whole transaction.
← Understanding read skew Found a mistake? Tell me and I'll fix it. Next: Write skew →