skip to content

Explain how an optimistic version column detects a lost update: what the UPDATE statement must look like, how a conflict is discovered, and what the application does next.

level: seniorimportance: must knowfreq 58%

answer

  1. SET v = v + 1 WHERE id = ? AND v = <read value>
  2. zero rows affected = conflict, never treat as success
  3. no lock held → survives think-time and stateless requests
  4. integer counter, not a timestamp
  5. high contention → retry storms; fall back to locks or atomic arithmetic

basics

~20 s

Store a version number on the row. Read it with the data, then write with ... SET data = ?, version = version + 1 WHERE id = ? AND version = <the value you read>. If zero rows are affected, someone changed the row first — reload, reapply, and retry or tell the user.

solid answer

~60 s

Add an integer `version` column. Every read carries it out with the data; every write includes it in the predicate and increments it: ```sql UPDATE documents SET title = ?, version = version + 1 WHERE id = ? AND version = ?; ``` Because a blocked update re-evaluates its predicate against the newest committed row version, a concurrent commit makes the `version` comparison fail and the statement affects **zero rows**. That zero is the conflict signal — the whole scheme depends on the application checking the affected-row count and never treating zero as success. On conflict you choose: retry automatically (re-read, recompute, re-issue) for machine-driven writes, or surface "this record changed since you loaded it" for human edits, ideally with a merge view. Strengths: no locks are held, so it works across think-time and stateless requests, and it costs nothing when there is no contention. Weaknesses: under heavy contention retries waste work and can starve writers; and every writer must include the version predicate or the protection leaks.

code

sql · 8 lines
sql
-- read
SELECT id, title, body, version FROM documents WHERE id = 10;  -- version = 7

-- write, minutes later, in a new transaction
UPDATE documents
   SET title = :title, body = :body, version = version + 1
 WHERE id = 10 AND version = 7;
-- 0 rows => conflict: reload version 8 and decide what to do

go deeper

for a junior

Be able to write the conditional UPDATE with the version predicate and say that zero rows affected means someone else got there first.

for a middle

Explain why the check-and-write is atomic (the predicate is re-evaluated against the newest committed row) and describe the reload-and-retry flow.

for a senior

Design the conflict policy — retry vs surface vs merge — enforce the predicate centrally, and know when contention makes optimistic control the wrong tool.

for a principal

Treat it as a system-wide protocol: where the version lives (row vs aggregate), how conflicts surface in the API and UI, retry budgets, and the multi-row invariants it cannot cover.

## The idea Pessimistic locking assumes a conflict and prevents it. **Optimistic concurrency control** assumes conflicts are rare, lets everyone proceed, and *detects* the conflict at write time. The detector is a version column: a monotonically increasing integer stored on the row and bumped by every write. ## The protocol 1. **Read** the row along with its version: `SELECT id, title, body, version FROM documents WHERE id = ?` → version 7. 2. **Work** — arbitrarily long. Application logic, a user filling in a form for ten minutes, a workflow across several HTTP requests. Nothing is locked and no transaction is held open. 3. **Write conditionally**: ```sql UPDATE documents SET title = ?, body = ?, version = version + 1 WHERE id = ? AND version = 7; ``` 4. **Inspect the affected-row count.** One row → your write applied and the version is now 8. Zero rows → somebody else committed a change since you read, the row's version is no longer 7, and *your write did not happen*. ## Why zero rows is a reliable signal A row-level `UPDATE` takes the exclusive row lock; if another transaction holds it, yours waits and then re-evaluates its `WHERE` clause against the newest committed version of the row. So the version comparison is always made against current data, not against a snapshot. The check and the write are therefore one atomic compare-and-set, with no window between them. This holds regardless of isolation level for the single-statement case, which is what makes the technique portable. The corollary is the number-one implementation bug: **ignoring the affected-row count**. Frameworks that fire-and-forget an update turn a detected conflict back into a silent lost update. If you take one thing from this topic, take that. ## Handling the conflict Detection is only half the design; the other half is policy. - **Automatic retry** suits machine-driven writes where the new value can be recomputed: re-read the row (getting version 8), re-apply the intended change, re-issue. Bound the retries (3–5), add jitter, and give up with a clear error rather than looping forever. - **Surface it to the user** for human edits: "this record was changed by someone else since you opened it." Blindly retrying here would re-apply the user's stale form values on top of the other person's edit — which is exactly the lost update you were preventing, laundered through a retry loop. - **Merge** where the domain allows it: if the two writers touched disjoint fields, present a merge or apply field-level updates so both survive. This requires writing only dirty columns, not the whole row. ## Design details that matter **Every writer must participate.** A code path that updates without the version predicate silently bypasses the scheme and can also leave the version un-incremented, so *other* writers stop detecting conflicts. Enforce it centrally — in the data-access layer, an ORM's version support, or a trigger that increments the version on every update — rather than by convention. **Version vs timestamp.** An integer counter is the safe choice. A last-modified timestamp is tempting but risks equal values within the clock's resolution, clock skew across nodes, and daylight/NTP adjustments; two writes in the same millisecond then look identical. If you must use a timestamp, make sure it is generated by the database and has sufficient resolution — and prefer the integer anyway. **Scope of the version.** One version per row is the standard. A version per aggregate (parent row bumped by any child change) protects cross-row invariants but increases contention on the parent. Choose deliberately. **Deletes.** A conditional delete (`DELETE ... WHERE id = ? AND version = ?`) gives the same protection; zero rows means either the row was changed or already deleted, and the application must distinguish those if it matters. **Read-only readers** do not need the version, but any read that will feed a write must carry it, including reads that go through caches — a cached row with a stale version simply produces a conflict, which is correct behaviour, though it can produce a retry storm if the cache is not invalidated. ## Optimistic versus pessimistic | | Optimistic version check | Pessimistic row lock | |---|---|---| | Holds a lock while thinking | No | Yes | | Works across requests / think-time | Yes | No | | Cost under no contention | Near zero | A lock acquisition and hold | | Behaviour under high contention | Retries, wasted work, possible starvation | Queueing, bounded but serialized | | Failure mode | Late failure after the work is done | Blocking, lock-wait timeout, deadlock | Under heavy contention on one row, optimistic control degrades badly: many writers do the work, one wins, the rest throw it away and try again, and long-running writers can starve behind short ones. That is the point at which you either switch to a lock, express the change as an atomic in-place arithmetic update, or redesign the hot row away (shard the counter, append events and aggregate). ## What it does not solve A version column protects **one row**. If your invariant spans rows — "at least one doctor must remain on call", "the sum of these lines must equal the header total" — checking each row's version independently can still allow two transactions to break the invariant together, because each row they wrote was unchanged. That class of problem needs a serializable isolation level, a lock on a common parent row, or a constraint that materializes the invariant in a single row.

  • When should the application NOT auto-retry after a version conflict?
    When the new value came from a human and cannot be recomputed. Re-issuing the same form values against the newer version simply overwrites the other person's edit, reproducing the lost update inside the retry loop. In that case surface the conflict, show what changed, and let the user re-apply or merge. Auto-retry is for writes where the intended change can be recomputed from freshly read state.
  • Why is a last-modified timestamp a weaker version token than an integer counter?
    Two writes landing inside the clock's resolution produce identical timestamps, so a conflicting write can look unchanged and slip through. Clock skew between nodes and NTP adjustments make it worse, and a timestamp generated by the application rather than the database compounds the problem. A database-incremented integer is unconditionally monotonic per row and has none of these failure modes.
  • Does a per-row version column protect an invariant that spans several rows?
    No. Each transaction can update a different row, find its own version unchanged, and commit — yet together they break a rule such as 'at least one person must remain on call'. Protecting a multi-row invariant needs serializable isolation, a lock on a shared parent row, or materializing the invariant into a single row or constraint that both writers must touch.

Editing a wiki page: you save with the revision number you loaded. If the page has moved on, the save is rejected with 'edit conflict' rather than quietly replacing the newer text.

saying these in an interview costs you the question

  • Not checking the affected-row count, which converts a detected conflict back into a silent lost update
  • Auto-retrying a human's stale form data, re-creating the lost update inside the retry loop
  • Leaving one code path that updates without the version predicate or without incrementing the version
  • Using a last-modified timestamp with coarse resolution or application-generated clocks as the version token
  • Claiming a per-row version protects invariants that span multiple rows
  • Retrying unbounded under contention instead of switching strategy

context