skip to content

A request loads an entity, changes a field in memory, and the ORM issues the UPDATE at commit. The database runs at READ COMMITTED isolation. Does that isolation level stop a concurrent request from silently overwriting the change, and if not, what does?

level: seniorimportance: must knowfreq 50%

answer

  1. Read and write are separate statements = lost-update window
  2. READ COMMITTED takes no lock on plain SELECT
  3. WHERE version = ? turns overwrite into zero rows updated
  4. Locking read serialises the hot row
  5. balance = balance + ? has no window

basics

~20 s

No. READ COMMITTED takes no lock on a plain read, so two sessions can read the same row and the second UPDATE simply overwrites the first — a lost update. You need a version check in the UPDATE's WHERE clause, a locking read, or a single atomic UPDATE statement.

solid answer

~60 s

READ COMMITTED guarantees only that each statement sees committed data. It says nothing about a read and a write that happen in separate statements, which is exactly the ORM's shape: SELECT at load, UPDATE at flush, with the whole request in between. So two requests both read balance = 100, one writes 110, the other writes 120, and the first change is gone with no error anywhere. The read-modify-write window is what matters, and the ORM makes it long. Three honest fixes. Add a version column so the generated UPDATE carries a WHERE version = ? predicate: zero rows updated means someone else won, and the ORM turns that into a conflict exception you retry. Or take a locking read at load time so the row is held from SELECT to commit, serialising the writers at the cost of contention. Or skip the read entirely and express the change as one statement, such as UPDATE account SET balance = balance + ? WHERE id = ?, which is atomic in the engine. Raising isolation helps only on engines that detect write conflicts, and it converts the problem into serialization failures you must retry.

code

sql · 7 lines
sql
-- session A                         -- session B
SELECT balance FROM account WHERE id=1;   -- 100
                                    SELECT balance FROM account WHERE id=1; -- 100
UPDATE account SET balance=110 WHERE id=1;
COMMIT;
                                    UPDATE account SET balance=120 WHERE id=1;
                                    COMMIT;   -- A's +10 is gone, no error

go deeper

for a junior

Recognise that reading and later writing can let two users overwrite each other, and that the database's default isolation does not stop it.

for a middle

Explain the read-modify-write window, why the plain SELECT takes no lock, and name the version predicate and the locking read as the two standard remedies.

for a senior

Compare the three remedies by contention profile and retry cost, and describe the recovery path after a conflict — new persistence context, re-read, bounded retry.

for a principal

Decide per operation rather than globally, keep isolation at the default and apply targeted mechanisms, monitor conflict rates as a design signal, and consider remodelling hot rows so contention disappears instead of being managed.

## Why the ORM shape is the problem A lost update needs two ingredients: a read, then a write derived from it, in separate statements. An ORM produces exactly that by design. You load an entity at the start of a request, business code changes fields, and the UPDATE is generated at flush from the difference between the current values and the snapshot taken at load. Between those two statements sits the entire request — validation, other queries, sometimes remote calls. Under READ COMMITTED, the SELECT takes no lasting lock. Another session can read the same row, modify it, and commit before your UPDATE runs. Your UPDATE then writes the full column set computed from stale inputs, and their change disappears. Nothing raises an error, because from the engine's point of view both transactions did legal work. ## What isolation does and does not cover The part owned by the engine is what a read sees. Higher isolation can extend that: a snapshot fixed for the whole transaction, or conflict detection that aborts one of two transactions that touched the same row. Whether raising the level actually prevents this specific anomaly is engine-specific, and that is the trap in the interview answer. On some engines, an elevated level detects the write conflict and aborts one transaction with a serialization error; on others, the higher level changes only what plain reads observe while the UPDATE still reads and overwrites the latest committed row. Either way you have to say what the application does next — and if the answer is a serialization error, the application must handle it. So the reliable statement is: isolation is not a substitute for an explicit conflict-detection mechanism when the read and the write are separate statements executed at application speed. ## The three application-level options 1. Version check at write time. A version column makes the generated statement UPDATE ... SET ..., version = old + 1 WHERE id = ? AND version = old. If a concurrent writer already bumped the version, the update affects zero rows, the ORM notices the mismatch between expected and actual row count, and raises a conflict. No locks are held between read and write, so throughput stays high; the price is that the loser must redo the work in a fresh persistence context. This is the default choice for interactive workloads. 2. Locking read. Ask for the row with a lock at load time so the engine emits SELECT ... FOR UPDATE (or FOR SHARE for a read lock). The row is held until commit, so the second writer waits and then reads your committed value. Correct and simple, but it serialises the hot row, holds locks for the whole request, and introduces lock-wait timeouts and deadlock risk when several rows are taken in inconsistent order. Suitable for short, contended, must-not-fail sections. 3. Remove the read. If the change is relative rather than absolute — increments, decrements, appends, state transitions guarded by a predicate — express it as one statement. UPDATE account SET balance = balance + ? WHERE id = ? has no window at all, and adding AND balance + ? >= 0 makes the invariant part of the statement. In an ORM this is a bulk update or a native statement, and it bypasses the persistence context, so anything already loaded must be refreshed or the context cleared afterwards. ## Choosing between them Ask how often conflicts really occur and what a conflict costs. Rare conflicts plus cheap retries favour the version check. Frequent conflicts on a small set of hot rows favour a locking read, because optimistic retries under heavy contention waste work and can livelock. Pure counters and relative updates favour the single statement, which is both the fastest and the least error-prone. They combine: a version column protects the general case while a handful of hot operations use one atomic statement. ## Practicalities the interviewer listens for Be explicit that the window is the whole request, not just the flush. Point out that partial updates — dynamic update generation that writes only changed columns — narrow but do not close the hole, because two requests changing the same column still collide. Mention that any of the failure modes here (conflict exception, serialization failure, lock timeout) leaves the persistence context unusable, so recovery means a new one and a re-read, and that retries must be bounded. And note the observability angle: conflicts should be counted, because a rising rate is a design signal that the contended row wants a different model.

  • Does raising the isolation level to REPEATABLE READ or SERIALIZABLE remove the need for an application-level check?
    Not reliably, and never for free. Whether the anomaly is detected depends on the engine's implementation, and where it is detected the application receives a serialization failure that it must catch, discard the persistence context, and retry. You have therefore replaced a conflict exception with a different conflict exception, while paying the cost globally on every transaction rather than only on the rows that need it.
  • When would you prefer a locking read over an optimistic version check?
    When conflicts are frequent on a small number of hot rows and redoing the work is expensive or user-visible. Optimistic retries under heavy contention burn work repeatedly and can starve some callers, whereas a locking read queues writers in order. The trade is held locks for the whole request, lock-wait timeouts, and deadlock risk if rows are acquired in inconsistent order.
  • Why does an atomic single-statement update need care in an ORM?
    Because it bypasses the persistence context. Entities already loaded keep their old field values and their loaded-state snapshots, so a later flush can write stale values back or dirty checking can behave oddly. After such a statement you must refresh the affected entities or clear the context, and note that no version column is bumped unless the statement bumps it explicitly.

saying these in an interview costs you the question

  • Claiming READ COMMITTED prevents lost updates because it only reads committed data
  • Believing an ORM's partial-column updates make the anomaly impossible
  • Assuming SERIALIZABLE is a drop-in fix with no retry logic
  • Retrying a conflict by reusing the same entity instances and persistence context
  • Treating a locking read as free, ignoring lock hold time and deadlock ordering

context