Skip to content

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?

Pick one

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

Read-modify-write: SELECT a value, compute in the app, UPDATE with the result. Two sessions that both read before either writes write from the same value.
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

a woman in a green blazer turns a lever on a green mechanical tally machine with number wheels and gears, and holds her other palm up; two men in work overalls stand behind her, waiting their turn.
An illustration to help it stick. The mechanism is in the sections above.

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?

Code for this question
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]);}
Pick one

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?

Code for this question
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');
Pick one

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?

Pick one

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?

Code for this question
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();
Pick one

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?

Pick one

Go deeper

Pro tips
  1. Log the UPDATE's row count on every optimistic or conditional write. When: users report "I saved, and my change vanished" (steps 4, 8).
  2. 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 is lib/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 UPDATE searches 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 UPDATE prevents 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, 25P01 no_active_sql_transaction, 25P02 in_failed_sql_transaction, 55P03 lock_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).

Checked against the tools and docs as of 2026-09-27.