Why does an in-place update such as `UPDATE accounts SET balance = balance - 10 WHERE id = 42` avoid a lost update, while reading the balance into application code and writing back the computed number does not?
answer
- read and write inside one statement, under the row write lock
- blocked writer re-reads the newest committed version
- writes always see the current row, not the snapshot
- add the guard to WHERE and check affected rows
- hot row = one lock queue
basics
~20 sThe in-place form reads and writes inside one statement while holding the row's write lock, so no other transaction can slip in between. The application version leaves a gap between the read and the write in which another transaction can commit a change you never see.
solid answer
~60 sIn `SET balance = balance - 10`, the value on the right-hand side is fetched by the engine at write time, under the exclusive row lock that the update itself takes. Read and write are one indivisible step: a competing update on the same row either waits for the lock or is already finished, and after it commits the waiting statement re-evaluates `balance` against the newest committed version. Concurrent decrements therefore compose correctly. The application round-trip splits that into two statements with network latency, application logic and possibly user think-time in between. Whatever you write is derived from a value that may already be obsolete. Limits worth stating: this works when the new value is a pure function of the old one expressible in SQL — mostly commutative arithmetic. It does not help when the new value depends on business logic, another service, or several rows, and it does not by itself enforce invariants like `balance >= 0`; add that to the `WHERE` clause or a `CHECK` constraint and inspect the affected row count.
code
sql · 5 linesUPDATE accounts
SET balance = balance - 10
WHERE id = 42
AND balance >= 10;
-- 0 rows affected => insufficient funds, nothing was writtengo deeper
Say that the database computes the new value at write time under a lock, so nothing can sneak in between, whereas the read-then-write version has a gap.
Describe the block-and-re-read behaviour of the second writer, and show putting the guard condition in WHERE plus checking rows affected.
Add the snapshot-isolation variant where the waiting transaction aborts with a serialization failure, and discuss hot-row contention and counter sharding.
Position it as the cheapest correct option for commutative single-row writes, and define when the write path must escalate to locks, version checks, or an append-and-aggregate design.
## The mechanical difference Compare two ways to decrement a balance by 10. **Application-side read-modify-write** ``` SELECT balance FROM accounts WHERE id = 42; -- 100 -- application computes 100 - 10 = 90 UPDATE accounts SET balance = 90 WHERE id = 42; ``` **In-place update** ``` UPDATE accounts SET balance = balance - 10 WHERE id = 42; ``` The first form contains a *window*: between the `SELECT` and the `UPDATE` there is a network round trip, application code, and possibly seconds of user think-time. Any transaction can commit a change to the row inside that window, and nothing in the second statement notices — it sets a constant. The second form has no window. A single-row `UPDATE` is executed by the engine as one indivisible step: it locates the row, takes an **exclusive row-level write lock**, reads the current value of `balance` under that lock, computes the new value, and writes it. No other transaction can interleave inside a step it cannot enter. ## What happens when two of them collide Suppose T1 and T2 both issue `SET balance = balance - 10` at the same instant. - T1 acquires the row's write lock first and computes 100 → 90. - T2 finds the row locked and **blocks**. It is not reading anything yet. - T1 commits and releases the lock. - T2 wakes up. It does not use the value it might have seen earlier; it re-reads the newest committed version of the row — 90 — and computes 80. The two decrements compose correctly. This re-evaluation on wake-up is the crucial part, and it is why *writers see the latest committed row even in a multiversion engine that would otherwise show them an older snapshot*. Writes always operate on the current version of the row; only reads follow a snapshot. One wrinkle to know: under a transaction-wide snapshot (`REPEATABLE READ` in a snapshot-based engine), re-reading a newer version would contradict the transaction's own snapshot, so the engine instead aborts the waiting transaction with a **serialization failure** and expects the application to retry. Under `READ COMMITTED` it re-evaluates and proceeds. Either way the update is not lost — you either get the correct value or a loud error, never silent overwriting. ## Why the `WHERE` clause matters too The re-evaluation on wake-up also re-checks the predicate. `UPDATE orders SET status = 'SHIPPED' WHERE id = 7 AND status = 'PENDING'` is safe even under concurrency: if another transaction already shipped it, the waiting statement re-evaluates the predicate against the new version, matches nothing, and reports **zero rows affected**. Checking the affected-row count turns the statement into a compare-and-set. This is the single most useful trick in the pattern, and candidates who mention it stand out. Similarly, `UPDATE accounts SET balance = balance - 10 WHERE id = 42 AND balance >= 10` both performs the arithmetic atomically and enforces the invariant. If it reports zero rows, the withdrawal was rejected — no negative balance is possible even under contention. ## When the in-place form is not available - The new value is not a function of the old one that SQL can express: it depends on a pricing service, a machine-learning score, a file, or user input. - The write spans multiple rows or multiple tables and must be consistent across them. - The user edits a whole record over minutes of think-time — no single statement can span that. This is where a version column belongs. - The value is not commutative and the *order* of application matters for business reasons. ## Costs and caveats - **Hot-row serialization.** Every concurrent writer of the same row queues behind one lock. A single global counter updated thousands of times per second becomes the bottleneck; the standard remedy is to shard the counter into N rows and sum them on read, or to append events and aggregate. - **Blocking, not failing, is the default.** Long-running transactions that hold the lock stall every other writer, and lock-wait timeouts appear under load. - **It does not make the whole business operation atomic.** If the debit and the credit are separate statements, wrap them in a transaction — the atomic update only protects each row's own read-modify-write. - **Do not read the row afterwards to "confirm" the value** unless you need it; if you do need the new value, ask for it in the same statement where the engine supports returning updated rows, rather than issuing a second `SELECT`. ## The rule to remember Move the computation to where the lock is. If the database can compute the new value from the old one, let it; if it cannot, you need a lock held across your read and write, or a version check that fails the write when the row moved underneath you.
- Two transactions issue the same in-place decrement at once. Walk through what the second one does.It tries to take the exclusive row lock, finds it held, and blocks. When the first transaction commits and releases the lock, the second re-reads the newest committed version of the row and recomputes its arithmetic against that value, so the two decrements compose. Under a transaction-wide snapshot the engine cannot show it a newer version, so it aborts with a serialization failure instead and the application retries.
- How do you turn an in-place update into a safe compare-and-set?Put the expected precondition in the WHERE clause — the old status, a version number, or a minimum balance — and inspect the affected-row count. Because a blocked update re-evaluates its predicate against the latest version of the row, a concurrent change makes the predicate fail and the statement reports zero rows. The application then knows the operation was rejected rather than silently applied to stale data.
saying these in an interview costs you the question
- Claiming the two forms are equivalent because 'they're both one UPDATE in the end'
- Thinking a multiversion engine lets a writer update an old snapshot version of the row
- Ignoring the affected-row count, so a compare-and-set that matched nothing looks like success
- Using SET x = x + 1 for logic that cannot be expressed as arithmetic on the old value, or for cross-row invariants
- Forgetting that a single hot row still serializes every writer behind one lock