skip to content

Two concurrent transactions, both at the READ COMMITTED isolation level, each read an account's balance, compute a new value in application code, and write it back. Explain what goes wrong and what your options are to prevent it.

level: middleimportance: must knowfreq 62%

answer

  1. Both read 100, second write wins, no error
  2. Level does not detect it — application shape does
  3. SET x = x - n WHERE guard: one statement wins
  4. FOR UPDATE = lock at read time
  5. version = :v in WHERE, zero rows = retry

basics

~20 s

One update is silently lost: both read 100, both write their own result, the second overwrites the first and no error is raised. Fixes: do the arithmetic in one UPDATE statement, lock the row on read with SELECT ... FOR UPDATE, or use an optimistic version column and retry when zero rows change.

solid answer

~1 min

This is the **lost update** anomaly, and READ COMMITTED does nothing about it: both transactions read committed data and both wrote committed data, so no rule was broken. The engine reports success to both; the first writer's change simply disappears. Three standard remedies, in rough order of preference: 1. **Make it one statement.** `UPDATE account SET balance = balance - 50 WHERE id = 1 AND balance >= 50`. The engine serialises on the row lock and recomputes the expression against the current version, so nothing is lost. Check the affected-row count — zero means the guard failed. 2. **Pessimistic lock.** `SELECT balance FROM account WHERE id = 1 FOR UPDATE` takes the row's write lock at read time, so the second transaction blocks until the first commits and then reads the new value. Use when the computation genuinely cannot be expressed in SQL. 3. **Optimistic version.** Read `(balance, version)`, then `UPDATE ... SET balance = ?, version = version + 1 WHERE id = ? AND version = ?`. Zero rows affected means someone beat you; re-read and retry. Best under low contention and across long user-think-time gaps. Raising the isolation level is a fourth option but a blunter one.

code

sql · 9 lines
sql
SELECT balance, version FROM account WHERE id = 1;
-- returns balance=100, version=7; application computes 50

UPDATE account
   SET balance = 50,
       version = version + 1
 WHERE id = 1
   AND version = 7;
-- 0 rows affected -> someone committed first; re-read and retry

go deeper

for a junior

Describe the interleaving and name the single-statement UPDATE that computes from the stored value as the fix.

for a middle

Contrast pessimistic FOR UPDATE with an optimistic version column, and explain why zero affected rows is the conflict signal.

for a senior

Discuss selection criteria — read-to-write gap length, contention, retry cost, lock hold time across network calls — plus deadlock ordering and constraints as a backstop.

for a principal

Frame it as where the invariant should live: in the statement, in a lock protocol, in an optimistic protocol with retries, or in the isolation level — and the throughput and operational cost each choice imposes system-wide.

## The anomaly The interleaving is: 1. T1 reads `balance = 100`. 2. T2 reads `balance = 100`. 3. T1 computes `100 - 50 = 50` and writes 50, commits. 4. T2 computes `100 - 30 = 70` and writes 70, commits. Final balance: 70. Correct answer: 20. T1's withdrawal vanished. This is the **lost update**. Nothing in READ COMMITTED was violated. T2 read committed data (100 was committed at the time). T2 wrote after T1 committed, so there was no dirty write and no lock conflict to block on — by the time T2's `UPDATE` ran, T1's lock was gone. The engine has no way to know that T2's value 70 was *derived from* a version that is no longer current. That derivation lives in your application's memory, invisible to the database. This is the single most common real-world defect attributed to isolation levels, and it is a defect of application shape rather than of the level. ## Why the level cannot save you Some levels do detect this pattern — stronger snapshot-based levels typically abort one of the two transactions with a serialization failure because both modified a row the other had read. READ COMMITTED deliberately does not: its per-statement view means there is no transaction-scoped snapshot to violate, and its whole value proposition is not aborting things. So the responsibility is explicitly pushed to you. ## Remedy 1 — collapse the read-modify-write into one statement ``` UPDATE account SET balance = balance - 50 WHERE id = 1 AND balance >= 50; ``` The new value is expressed as a function of the *current stored value*, not of a value your process read earlier. Concurrent executions serialise on the row's write lock, and after a blocker commits, the expression and the guard are re-evaluated against the newest committed version. Both withdrawals apply; neither is lost. Two disciplines go with it: put every precondition into the `WHERE` clause (a check kept in application code is never re-evaluated), and treat an affected-row count of zero as a real business outcome — "insufficient funds" — not as an anomaly to ignore. This is the best answer whenever the computation is expressible in SQL, because it requires no extra round trips, no retry loop, and no lock held across network latency. ## Remedy 2 — pessimistic locking on read ``` BEGIN; SELECT balance FROM account WHERE id = 1 FOR UPDATE; -- application computes the new value UPDATE account SET balance = :new WHERE id = 1; COMMIT; ``` `FOR UPDATE` takes the row's exclusive write lock at read time. A second transaction reaching the same `SELECT ... FOR UPDATE` blocks until the first commits, and — importantly under READ COMMITTED — then re-reads the *newest committed* version, so it computes from 50, not from 100. Use this when the computation genuinely cannot be expressed in SQL — it involves an external service call, complex branching, or data the database does not hold. The costs are real: you hold a write lock for the entire duration of your application logic, so any latency in that logic becomes contention for everyone else, and you must lock rows in a consistent order to avoid deadlocks. Never hold such a lock across a user's think time. ## Remedy 3 — optimistic concurrency with a version column Add an integer `version` (or use a timestamp/row-version column) and make the update conditional on the version you read: ``` SELECT balance, version FROM account WHERE id = 1; -- 100, 7 UPDATE account SET balance = 50, version = version + 1 WHERE id = 1 AND version = 7; ``` If a competitor committed in between, `version` is now 8, the `WHERE` matches nothing, and the affected-row count is **zero**. You detect the conflict, re-read, recompute, and retry — usually with a bounded retry count so a pathological hot row cannot spin forever. This is the right shape when the gap between read and write is long — an editing form submitted minutes later, a workflow spanning requests — because it holds no locks during the gap. It is also what most object-relational mapping layers implement for their optimistic-locking feature. The trade is that you must write the retry loop and make the retried operation safe to re-run. ## Remedy 4 — raise the isolation level Moving the transaction to a stronger level makes the engine detect the conflict for you, generally by aborting one transaction with a serialization failure. You still need a retry loop, you now pay the cost on every statement in the transaction rather than on the one row that needs it, and you have changed behaviour for code you were not thinking about. Reach for it when there are *many* interacting invariants, not to fix one counter. ## Choosing Ask: can the new value be written as a function of the stored value? If yes, remedy 1, always. If not, is the read-to-write gap short and inside one transaction? Then remedy 2. Is the gap long or spanning requests? Then remedy 3. Only if the invariant spans several rows and tables in ways none of these capture should you escalate the level. And note the free win: declarative constraints — `CHECK (balance >= 0)`, unique indexes, foreign keys — are enforced by the engine at write time regardless of isolation level, so they catch a class of these races unconditionally. They should back up whichever remedy you choose.

  • When would you choose optimistic version checking over SELECT ... FOR UPDATE?
    When the interval between reading and writing is long or spans separate requests — an edit form, a multi-step workflow — because optimistic checking holds no lock during that interval and cannot block other users behind a slow client. It also suits low-contention data where conflicts are rare enough that occasional retries cost less than universal locking. Pessimistic locking wins when contention is high and retrying is expensive.
  • Your conditional UPDATE reports zero rows affected. What should the application do?
    Treat it as a real outcome, not an error to swallow. Under an optimistic version check it means someone else committed first, so re-read the row, recompute, and retry within a bounded attempt count. Under a business guard such as balance >= 50 it means the precondition failed, so report insufficient funds rather than retrying.
  • Does adding a CHECK constraint such as balance >= 0 solve lost updates?
    No, but it is a valuable backstop. Constraints are enforced at write time regardless of isolation level, so they will reject a write that would push the balance negative. They cannot detect that an update was computed from a superseded version when the resulting value is still legal — losing a 50 withdrawal and leaving 70 violates no constraint.

Two editors download the same document, each edits their copy, and each uploads. The upload succeeds both times; the second simply replaces the first. Nothing in the file server is broken — it never knew the second copy was based on a version that had already been superseded.

saying these in an interview costs you the question

  • Saying READ COMMITTED prevents lost updates because it prevents dirty reads
  • Believing the second UPDATE will block or error — by then the first transaction's lock is gone
  • Doing SELECT then UPDATE and calling it safe because both are inside one transaction
  • Using SELECT ... FOR UPDATE but holding it across user think time or a remote call
  • Ignoring the affected-row count after a conditional or version-guarded UPDATE

context