Data Loss Detection: Find Writes That Never Persist
Last verified on Nov 8, 2026.
A lost write is a save that the system acknowledged and then never kept. The user saw a success message, the API returned a 2xx and the next page load shows the old value. Nothing crashed, so nothing alerts.
Error monitoring cannot see this failure, because there is no error. You find it by checking that every acknowledged write has a matching durable outcome.
How writes get lost
The common causes are mundane:
- The handler answers before the work is done. HTTP defines 202 Accepted as a request accepted for processing where the processing has not been completed, and the request might never be acted on. Any route that returns 202 and enqueues a job can lose the write if the job fails.
- A catch-all error handler returns success after a failed insert.
- An UPDATE matches zero rows. PostgreSQL reports this as a command tag of
UPDATE 0, and a handler that ignores the count reports success for a write that touched nothing. - A transaction rolls back after the response was sent.
- The write goes to a cache or a replica and never reaches the primary store.
- Asynchronous commit settings trade durability for speed. PostgreSQL documents that with
synchronous_commitset tooff, a crash can lose recently reported commits, with a maximum delay of three timeswal_writer_delay.
Evidence in your telemetry
A lost write leaves a gap between two kinds of events: the request that was acknowledged and the write that should have followed. Example log lines for one request:
2026-11-08T09:14:02Z msg="request complete" request_id=r-5521 method=PUT route=/api/profile status=200
2026-11-08T09:14:02Z msg="profile update" request_id=r-5521 user_id=88 rows_affected=0The first line says success. The second shows no row changed. Neither is an error, so the pair is invisible unless you look for it.
User-side evidence is also useful: a form submit followed within seconds by a reload that returns the old value, or support tickets that say "my changes disappeared".
Detect it step by step
- Log the outcome of every write. Include
request_id, the rows affected, and whether the transaction committed. Never sample these lines, because a gap in sampled data looks identical to a lost write. - Find acknowledged writes with no persisted outcome. Leave a settling period so in-flight work is not counted:
-- Example: successful mutating requests with no commit logged for the same request
SELECT r.route, count(*) AS unmatched
FROM request_logs r
LEFT JOIN write_logs w
ON w.request_id = r.request_id AND w.committed = true AND w.rows_affected > 0
WHERE r.method IN ('POST', 'PUT', 'PATCH', 'DELETE')
AND r.status < 300
AND r.ts BETWEEN now() - interval '2 hours' AND now() - interval '10 minutes'
AND w.request_id IS NULL
GROUP BY r.route
ORDER BY unmatched DESC;- Add a read-back canary. A scheduled job writes a marker through the public API, waits for the longest normal replication or queue delay, reads it back and logs the result. It tests the whole path, including caches and replicas.
- Compare counts across stores. Orders accepted by the API against rows in the orders table, per hour, is a cheap reconciliation. The reconciliation mismatches guide goes deeper.
Thresholds and pitfalls
Alert on the ratio, not the raw count: unmatched acknowledged writes divided by all acknowledged writes per route. A healthy system sits near zero, so even a small sustained ratio on a payment or profile route is worth a look.
Watch for these false positives:
- Idempotent writes that legitimately change nothing. PostgreSQL counts matched rows in the
UPDATEtag even when values did not change, soUPDATE 0is a true no-match. Other databases may count differently, so check yours. - Asynchronous routes. Set the settling period to your slowest normal job time, not to a few seconds.
- Retried requests that reuse a request ID. Match on the idempotency key when you have one.
- Log pipelines that drop lines. If the write log is missing because ingestion lagged, you will see phantom losses. Check ingestion health first, and see the novel events guide for catching new messages that signal a changed code path.
What to do when it fires
Pick one affected request ID and trace it end to end. Check whether the handler returned before the commit, whether the job queue accepted the message and whether the database row exists under another key. If the write path swallows errors, make it fail loudly and return the real status.
For data already lost, restore from the source of truth: the event stream, the queue dead-letter store or a backup. Then add the canary so the next regression is caught within minutes.
The join above only works when request logs and write logs live in one searchable place. OpenTelemetry and Fluent Bit can ship both. Related guides cover stale data and field drift, the null and empty values that often accompany a broken write path.
Frequently Asked Questions
What is a lost write?
It is a write that the system acknowledged to the caller but never stored durably. The user sees success and the data is missing later.
Why does error monitoring miss lost writes?
There is no error. The handler returns a 2xx, so you need to check that each acknowledged write has a matching persisted outcome.
Does HTTP 202 mean the data was saved?
No. RFC 9110 defines 202 Accepted as accepted for processing with processing not completed, and the request might never be acted on.
How do I check for lost writes without changing the app?
Run a scheduled canary that writes a marker through the public API, waits for the normal delay and reads it back. Log the result.
Sources
- 202 Accepted means accepted for processing but not completed and might never be acted on; Retry-After semantics including with 503 (accessed Nov 8, 2026)
- UPDATE command tag returns the count of rows updated, including matched rows whose values did not change (accessed Nov 8, 2026)
- synchronous_commit off can lose recently reported commits; maximum delay is three times wal_writer_delay (accessed Nov 8, 2026)