Preventing lost updates
Hand the change to the database: a waiting UPDATE applies its arithmetic and its WHERE to the newest committed row, while app-computed values go stale.
Predict first
Before you read: predict
A candidate's extra_minutes is 0. Two proctor consoles each run, in their own transaction at Read Committed (the default), UPDATE attempts SET extra_minutes = extra_minutes + 10 WHERE id = 7. Console A's UPDATE runs first and is not yet committed; console B's UPDATE is sent and waits. Then A commits, and B commits.
What is extra_minutes?
The 30-second version
Read, compute in the app, write: two sessions read the same value, and the second write erases the first. At Read Committed, a waiting UPDATE re-reads the newest committed row and re-checks its WHERE, so put the arithmetic and the condition in the UPDATE. Otherwise lock the row, check a version, or retry the whole transaction.
Hand the change to the database: a waiting UPDATE applies its arithmetic and its WHERE to the newest committed row, while app-computed values go stale.
How it works
Step 1 of 11
In the kitchen
- two housemates read "£40", add their £10 in their heads, both write "£50" → read-modify-write loses one (step 1)
Analogy: A shared house's kitty and the tally sheet on the fridge
Housemates put cash in a tin; the sheet shows the total.
| In the house | In the database |
|---|---|
| two housemates read "£40", add their £10 in their heads, both write "£50" | read-modify-write loses one (step 1) |
| hand the tenner to the treasurer and say "add ten" | atomic UPDATE on the newest total (step 3) |
| take the sheet off the fridge until you have written | FOR UPDATE: others wait (steps 5–6) |
| "replacing line 7": refused if the sheet has reached line 8 | version check, UPDATE 0 (steps 7–8) |
| the treasurer voids an entry based on an older sheet; you start again | 40001 and a whole retry (steps 9–10) |
In real terms: a value your code computed from an earlier read is a guess; let the database apply the change, lock, compare a version, or retry.
Breaks down when: a housemate who sees "£50" where she wrote "£50" might notice the missing tenner. Wrong inference: "an overwrite shows up as an error." At Read Committed Postgres reports UPDATE 1 for the overwrite.
Contrasting case: a housemate who lives alone. No one else writes, so read, add and write is always right.
Examples, run for real
4.1 ExamHall: a check-in went missing
EXH-1220: two invigilator tablets checked candidates into room R2 at the same moment, and the room count was one short. The tablet code read the count, added 1 and wrote it back inside a transaction. MR !1420 moves the addition into the UPDATE. Same forced order:
4.1 two invigilator tablets check in one candidate each; room R2 has 40 checked in (Read Committed)
before (read, +1 in the app, write, in a transaction) both read 40/40 · checked_in 41 after 2 check-ins · B waited for A's lock, then wrote its own 41
after MR (UPDATE SET checked_in = checked_in + 1) checked_in 42 · B waited, then added 1 to A's committed row
alternative (SELECT ... FOR UPDATE, +1 in the app) checked_in 42 · B's SELECT waited for A's commit, then read 41
The transaction made B wait, yet B still wrote the value it had computed (step 2). The atomic statement and the locking read both worked from A's committed row (steps 3, 5).
4.2 Ticketing: two staff edit one price
Both forms were loaded at version 4; each save runs UPDATE events SET price = $1, version = version + 1 WHERE id = 12 AND version = $2.
4.2 two box-office staff edit one event's price; both forms were loaded at price 45, version 4
one after the other A sets 50: UPDATE 1 -> 200 saved · B sets 55: UPDATE 0 -> 409 changed by someone else · row price 50 v5
overlapping saves A: UPDATE 1 · B waited for A's lock, re-checked version = 4 on A's row: UPDATE 0 · row price 50 v5
handler ignores the count B is told "200 saved" · row price 50 v5 · B's 55 was never written
The version turned a silent overwrite into a 409 in both orders (steps 4, 7). Ignoring the count told B a save succeeded that never happened (step 8).
4.3 Stricter levels
The 4.1 addition at Repeatable Read, then a limit of 4 tickets per customer: the customer holds 2, and two orders of 2 each count, check and insert.
4.3 the same +1 at REPEATABLE READ, then a limit that spans rows
RR, no retry B: ERROR 40001 "could not serialize access due to concurrent update" · checked_in 41
RR, whole-transaction retry B: attempt 1 40001, attempt 2 committed · checked_in 42
READ COMMITTED order A +2 · order B +2 · customer holds 6 (limit 4)
SERIALIZABLE order A +2 · order B attempt 1 40001, attempt 2: limit reached · customer holds 4 (limit 4)
SERIALIZABLE order B attempt 1 failed at its INSERT: 40001 "could not serialize access due to read/write dependencies among transactions"
Which level aborts which conflict is db-isolation-anomalies' (linked). What this lesson adds is the retry: each second attempt read the other commit, so the addition landed and the order over the limit was refused (steps 9–10).
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
Seat holds for a section run through a pg Pool:
Two holds arrive together on a section with 10 held, and afterwards it shows 11.
Which is the bug?
async function holdSeat(pool, sectionId) { const { rows } = await pool.query('SELECT held FROM sections WHERE id = $1 FOR UPDATE', [sectionId]); await pool.query('UPDATE sections SET held = $1 WHERE id = $2', [rows[0].held + 1, sectionId]);}Question 2 of 5
A stock reservation runs at Repeatable Read:
Stock is 10. Two orders of 3 run at once; the second one's UPDATE fails with 40001.
Which handling of 40001 confirms both orders and leaves the right stock?
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ');const { rows } = await client.query('SELECT stock FROM products WHERE sku = $1', [sku]);if (rows[0].stock < qty) throw new Error('out of stock');await client.query('UPDATE products SET stock = $1 WHERE sku = $2', [rows[0].stock - qty, sku]);await client.query('COMMIT');Question 3 of 5
Transcode job 5 has status = 'queued'. Two workers each run, at Read Committed: BEGIN; SELECT id, status FROM jobs WHERE id = 5 AND status = 'queued' FOR UPDATE; if a row came back, UPDATE jobs SET status = 'running' and log a start; COMMIT. Worker A's SELECT returns the row; worker B's SELECT is sent and waits. A sets 'running' and commits.
What does B's SELECT return?
Question 4 of 5
A merchant's payout settings form is loaded with the row's version. The save handler:
Two admins open the form at version 8. Admin A saves Friday and gets 200; later admin B saves Monday, also gets 200, and Friday is gone. No save has ever returned 409.
Which is the bug?
const r = await db.query( 'UPDATE merchants SET payout_day = $1 WHERE id = $2 AND version = $3', [form.payoutDay, merchantId, form.version]);return r.rowCount === 0 ? res.status(409).end() : res.status(200).end();Question 5 of 5
Four users like one chat message at the same moment (Read Committed, the default). The team has three goals: every like is counted; no like request is ever sent back to start over; the row is never locked while the app computes a new value.
Which design meets all three?
Go deeper
Pro tips
- Log the UPDATE's row count on every optimistic or conditional write. When: users report "I saved, and my change vanished" (steps 4, 8).
- Put the retry loop around the whole transaction function, not inside it, and cap it. When: moving any code path to Repeatable Read or Serializable (steps 9–10). Verify (RAN,
practice.mjs, 50 runs): a retry inside the aborted transaction got 25P02; a whole-function retry succeeded on attempt 2. The pack's wrapper islib/tx.mjs.
Source
- PostgreSQL 17 documentation, 13.2 Transaction Isolation: https://www.postgresql.org/docs/17/transaction-iso.html · fetched 2026-09-27. Read Committed: a would-be updater "will wait for the first updating transaction to commit or roll back"; if it commits, the second updater "will attempt to apply its operation to the updated version of the row", and the WHERE "is re-evaluated to see if the updated version of the row still matches", and
SELECT FOR UPDATEsearches for target rows as UPDATE does. Repeatable Read: if the first updater rolls back, the transaction "can proceed with updating the originally found row"; if it commits, the transaction is rolled back with "could not serialize access due to concurrent update", and the application "should abort the current transaction and retry the whole transaction from the beginning". Serializable: applications "must be prepared to retry transactions due to serialization failures", which "always return with an SQLSTATE value of '40001'" (steps 9–10). Valid as of 2026-09-27. - PostgreSQL 17 documentation, 13.3.2 Row-Level Locks: https://www.postgresql.org/docs/17/explicit-locking.html · fetched 2026-09-27.
FOR UPDATEprevents rows being "locked, modified or deleted by other transactions until the current transaction ends", and "will then lock and return the updated row"; "Row-level locks are released at transaction end or during savepoint rollback". Step 5 names Read Committed because "Within a REPEATABLE READ or SERIALIZABLE transaction, however, an error will be thrown if a row to be locked has changed since the transaction started." Valid as of 2026-09-27. - PostgreSQL 17 documentation, UPDATE, Outputs: https://www.postgresql.org/docs/17/sql-update.html · fetched 2026-09-27. "If count is 0, no rows were updated by the query (this is not considered an error)". Valid as of 2026-09-27.
- PostgreSQL 17 documentation, Appendix A. Error Codes: https://www.postgresql.org/docs/17/errcodes-appendix.html · fetched 2026-09-27. 40001
serialization_failure, 25P01no_active_sql_transaction, 25P02in_failed_sql_transaction, 55P03lock_not_available. Valid as of 2026-09-27. - Kleppmann, Designing Data-Intensive Applications, ch. 7, "Preventing Lost Updates" (curriculum source; not re-read for this lesson). Names the same families: atomic writes, explicit locking, compare-and-set, and automatic detection by the database (step 11's menu).
Related lessons
Checked against the tools and docs as of 2026-09-27.