Databases · intermediate
Optimistic version lost update: prove which writer ignored the revision
A disciplined playbook for proving which writer ignored a revision in an optimistic version lost update. It sequences a 10-minute triage, shows how to read PostgreSQL's pg_stat_database statistics and pg_locks views, and defines the exact UPDATE row counts and serializable failures that name the writer.
The symptoms
- •Final stored value matches an earlier read, not the most recent read, even though the application logged a successful UPDATE.
- •Two concurrent writers each return success, but the row ends in a state that corresponds to neither writer's full edit; one writer's diff is silently dropped.
- •UPDATE statements with no version predicate complete in microseconds under contention, while the same statement with WHERE version = $1 occasionally returns 0 rows even when the row exists.
- •Application logs show two writers that both report "updated 1 row" within the same millisecond window, after which the row reflects only one of the two payloads.
- •Conflict metrics or deadlocks are not reported, yet user-visible state regresses after concurrent edits.
Likely causes
- •An UPDATE statement is issued without a version predicate, so a writer that read version N overwrites a row that has already advanced to version N+1 by another writer.
- •A repository or ORM helper silently strips the version column from the WHERE clause, so the conditional update degrades into an unconditional UPDATE that matches every time.
- •The version column is read inside a transaction, but the UPDATE is issued after a long-running read that re-uses a stale snapshot, so the conditional check sees a row that no longer reflects the latest commit.
- •Two writers each load version N, both compute their own version N+1, and both succeed because the application computed the next version from the read value rather than relying on a database-managed version source.
- •Batch or bulk update paths bypass the version check entirely, while single-row paths enforce it, so lost updates only appear under specific code paths.
- •A retry loop re-reads the row and re-applies the change, but reads inside a non-repeatable-read isolation level see a different version than the one used to compute the write.
First ten minutes
- 01Freeze writes to the affected row if the application supports a feature flag or maintenance switch, so the lost update does not progress while you collect evidence.
- 02Identify the exact table and the exact version column from the schema; record table name, column name, column type, and any default or trigger that mutates it.
- 03Pull the most recent N minutes of row history from the application log: who read the row, what version they saw, and the SQL they ultimately executed. Mark every UPDATE that lacks a version predicate.
- 04Inspect PostgreSQL's pg_stat_database for tup_updated, tup_hot_updated, and xact_commit on the database that owns the table to confirm the volume and pattern of updates at the moment of the incident.
- 05Inspect pg_locks for lock waits and granted locks on the row's relation during the incident window; record any blocking PIDs and the lock modes held.
- 06Read the application's structured audit log to find the two writers whose updates landed in the same window; record user, request id, read version, written version, and timestamp for each.
- 07Decide which writer ignored the revision by matching the persisted value and the read version recorded by each writer: the writer whose read version does not match the row's prior version is the suspect.
Evidence to collect
- •The persisted row's version before and after the incident, taken from a row history table, change-data-capture stream, or trigger-based audit log.
- •For each suspect writer: the read version, the read timestamp, the SQL text of the UPDATE, and the affected row count reported by the driver.
- •pg_stat_database counters for tup_updated and xact_commit on the target database, snapshotted at incident start and incident end, to bound the update volume.
- •pg_locks snapshots at the moment of the incident, including granted and awaited locks, relation OID, and the PID of the holding backend.
- •The application-level retry and re-read trace, which reveals whether a writer re-read the row after a partial failure.
- •The isolation level in effect for each transaction, taken from server configuration or session state, to rule out snapshot-based re-reads as an alternative explanation.
Where to look
- •The boundary between the application's read path and write path: the call site that issues SELECT followed by UPDATE, and the helper that constructs the UPDATE statement.
- •The repository or ORM mapping for the version column, where column annotations, interceptors, or update templates can silently drop the predicate.
- •The PostgreSQL catalog for the table: pg_attribute to confirm the version column and its type, and pg_class to confirm the relation OID used in pg_locks filtering.
- •The PostgreSQL statistics views: pg_stat_database for cumulative update activity, and pg_stat_user_tables for per-table update activity on the affected relation.
- •The application's request-scoped log fields, which carry the request id, user id, and the SQL statement bound parameters, to correlate readers and writers.
- •The database's session and transaction state, accessible via pg_stat_activity and the current_setting('transaction_isolation') output for the backends involved.
Diagnostic steps
- 01Reproduce in a controlled environment: open two sessions, have each read the same row at version N, then issue an UPDATE without a version predicate; observe that both report a row count of 1 and the final row reflects only the second writer.
- 02Compare row count semantics: a versioned UPDATE that returns 0 rows is the correct signal of a lost update; an unconditional UPDATE that returns 1 row is the failure signature. Capture the driver-level row count from each writer.
- 03Diff the SQL text of each writer's UPDATE. If one UPDATE contains WHERE id = $1 and the other contains WHERE id = $1 AND version = $2, the unconditional writer is the one that ignored the revision.
- 04Query pg_stat_database for tup_updated and xact_commit on the target database, then cross-check with pg_stat_user_tables for the table to confirm the update count matches the application's reported row count.
- 05Take a pg_locks snapshot during a deliberate reproduction to see whether the second writer waited on the first; the absence of a lock wait, combined with both updates succeeding, is consistent with a missed version check rather than a deadlock.
- 06Inspect pg_stat_activity for the two backends, recording their application_name, state, and current_setting('transaction_isolation') to rule out snapshot drift as the cause.
- 07Audit the ORM or repository for code paths that bypass the version predicate, including bulk update methods, raw SQL helpers, and any "force update" or "skip concurrency check" option.
- 08Match the persisted row's pre-update version to each writer's read version. The writer whose read version does not equal the pre-update version is the one that ignored the revision; the writer whose read version does equal it is the one that observed the correct state but was overwritten.
Common mistakes
- •Concluding that the database "lost the update" when the application issued an UPDATE without a version predicate; the database faithfully executed the statement it was given.
- •Pointing at lock contention or deadlock reports; an unconditional UPDATE that matches by primary key will not block on the same row, so absence of lock waits is expected.
- •Assuming the last writer to commit is the one that ignored the revision; commit order is not evidence of which writer held a stale read.
- •Reading only the application log and missing the SQL bound parameters; without the read version, you cannot tell which writer was stale.
- •Trusting an ORM's "automatic optimistic locking" without verifying that the version column is included in the WHERE clause of the generated UPDATE.
- •Conflating row count returned by the driver with semantic success; a row count of 1 from an unconditional UPDATE is exactly the failure signature.
Safe fixes
- •Conditional on evidence that the UPDATE lacks a version predicate: add WHERE version = $read_version to the UPDATE statement and treat a row count of 0 as a concurrency conflict that the caller must resolve by re-reading and re-applying.
- •Conditional on evidence that an ORM is dropping the version predicate: configure the mapping so the version column is part of the WHERE clause for every update path, including bulk updates, and add a test that asserts the generated SQL contains the version predicate.
- •Conditional on evidence that two writers computed their own next version: move version incrementation to a database-managed source such as a trigger or a row-level expression, so the next version is a function of the current row, not of the read snapshot.
- •Conditional on evidence that the retry path re-reads under a weaker isolation level: set the transaction isolation level explicitly for the read-modify-write transaction, and re-read inside the same transaction so the re-read is consistent with the write.
- •Conditional on evidence that audit data is missing: enable a row-level audit mechanism, such as a change-data-capture stream or a trigger-based history table, so every read and write is observable after the fact.
- •Conditional on evidence that bulk paths bypass the version check: route all writes through a single repository method that enforces the version predicate, and reject calls that supply a null or absent version.
Prove the fix
- 01Run a deterministic reproduction: two sessions read the same row at version N, then each issues a versioned UPDATE. The second writer must receive a row count of 0 and surface a concurrency conflict to the caller; the first writer's change must persist.
- 02Inspect the generated SQL for every update path, including bulk and force-update paths, and assert the presence of a version predicate in the WHERE clause.
- 03Add a regression test that interleaves two writers, asserts the post-condition that the row reflects exactly one of the two writes plus a monotonic version increment, and fails if both writes report success.
- 04Confirm via pg_stat_database and pg_stat_user_tables that the table's update count matches the application's reported successful update count, with no surplus updates attributable to lost-update overwrites.
- 05Re-run the incident scenario in staging with the same request id correlation and verify that the audit log records a concurrency conflict for the second writer, not two silent successes.
Prevention and next steps
- •Enforce a single repository method for writes that requires a non-null read version and includes it in the WHERE clause, and reject any code path that bypasses the method.
- •Add a schema-level check that the version column is non-null and monotonic, and add a test that asserts every UPDATE on the table includes the version predicate.
- •Keep an audit trail of reads and writes, with request id, user, read version, and written version, so future lost updates can be attributed to a specific writer without relying on log archaeology.
- •Review ORM and bulk update paths during code review, with a checklist item that confirms the version predicate is present in the generated SQL.
- •Monitor the ratio of successful UPDATEs to concurrency-conflict responses over time, and alert on a sudden drop in conflicts that coincides with sustained write traffic, which can indicate that the version check has been disabled.
Safe commands and checks
SELECT datname, tup_updated, xact_commit FROM pg_stat_database WHERE datname = current_database();
SELECT relname, n_tup_upd, n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = '<affected_table>';
SELECT pid, granted, mode, relation::regclass FROM pg_locks WHERE relation = '<affected_table>'::regclass;
SELECT pid, application_name, state, query FROM pg_stat_activity WHERE datname = current_database() AND state <> 'idle';
SELECT attname, atttypid::regtype FROM pg_attribute WHERE attrelid = '<affected_table>'::regclass AND attname = '<version_column>';
SHOW transaction_isolation;
SELECT current_setting('transaction_isolation');