CouchDB Conflicts Are Not Corruption: A Practical Guide to Deterministic Resolution
Two offline clients edit the same CouchDB document. After replication, users see different data depending on which leaf revision CouchDB returns. Conflicts are not corruption—they are MVCC working as designed. Here is how to detect and resolve them deterministically.
15 Aug 2026, 07:28 UTC

The sync that silently splits your data
Two technicians update the same work order on their laptops while offline. They reconnect, replication runs, and the document now has two valid versions. One technician sees "job complete"; the other sees "parts backordered." Nothing errored. CouchDB kept both revisions because that is exactly what a multi-version concurrency control (MVCC) store is supposed to do. The problem is not the database—it is an application that treats conflicts as exceptions instead of normal state.
Why CouchDB keeps every leaf
CouchDB identifies every document version with a revision token, _rev, such as 2-abc. When you update a document, you must supply the current _rev. If you send a stale token, CouchDB rejects the write with HTTP 409 rather than silently overwriting newer data.
That safety check works well for a single writer. It breaks down when two clients both fetch revision 1-xyz, go offline, and write independently. Client A produces 2-def; client B produces 2-ghi. When replication later delivers both to the same database, CouchDB does not choose a winner. It stores both as conflicting leaf revisions. A subsequent read returns one of them—usually the one with the higher revision number, but that is an implementation detail, not a guarantee your application should rely on.
Detecting conflicts before your users do
You do not need to guess whether a document is conflicted. Append ?conflicts=true to a document read:
# Run in a terminal. Requires read access to the target database.
curl -s "http://localhost:5984/<database>/<document_id>?conflicts=true"
If the document has unresolved conflicts, the response includes a _conflicts array listing the losing revision tokens:
{
"_id": "work-order-77",
"_rev": "2-def",
"status": "complete",
"_conflicts": ["2-ghi"]
}
For applications that replicate frequently, polling individual documents is inefficient. The _changes feed with style=all_docs returns every leaf revision, not just the winner. A background worker can watch this feed and flag conflicted documents immediately after replication, long before a user reads the record.
Resolving deterministically: a worked example
CouchDB will not merge conflicting leaves for you. Your application must inspect both versions, decide on a winner, and then physically remove the losers. The safest pattern is a deterministic merge: either last-write-wins based on a domain timestamp, or a field-level merge where each field keeps the value from the revision with the later updated_at timestamp.
Assume work-order-77 is conflicted with revisions 2-def and 2-ghi. The resolution sequence is:
1. Fetch the current winner and the conflict list. Always read the document first; never construct or parse _rev strings manually.
curl -s "http://localhost:5984/<database>/work-order-77?conflicts=true"
2. Compute the merged document in your application. For this example, the merge keeps status from 2-ghi because its domain timestamp is later, and preserves notes from both revisions.
3. Write the merged document using the winning _rev. If you write against a losing revision, CouchDB returns HTTP 409.
# Replace <database> and <winning_rev> with values from step 1.
curl -X PUT "http://localhost:5984/<database>/work-order-77?rev=<winning_rev>" \
-H "Content-Type: application/json" \
-d '{
"status": "backordered",
"notes": "Technician A: parts delayed. Technician B: customer notified.",
"updated_at": "2026-10-15T14:00:00Z"
}'
4. Delete every losing leaf individually. A single DELETE removes only the revision you name. If there are multiple conflicts, repeat the command for each _rev in the _conflicts array.
# Replace <losing_rev> with each token from the _conflicts array.
curl -X DELETE "http://localhost:5984/<database>/work-order-77?rev=<losing_rev>"
5. Verify. Re-run the read from step 1. A successful resolution returns no _conflicts array.
curl -s "http://localhost:5984/<database>/work-order-77?conflicts=true"
Risk: Deleting a losing revision is irreversible. If your merge logic is wrong, the discarded data is gone. Test merge rules against a staging database before running them in production.
The limitation: compaction is not resolution
Database compaction reclaims disk space by removing the bodies of superseded revisions. It does not inspect _conflicts, choose a winner, or delete losing leaves. A conflicted document remains conflicted after compaction; only the historical bodies of old, non-conflicting revisions are purged.
This leads to a common operational mistake: scheduling aggressive compaction to "clean up" conflicts. That increases I/O without reducing the conflict count. Compaction and conflict resolution are independent maintenance tasks. Pair them, but never substitute one for the other.
An actionable checklist
- Fetch before every update. Send the current
_revand treat HTTP 409 as a signal to re-read and re-merge, not as an error to retry blindly. - Monitor
_changes?style=all_docsin a background process so conflicts are detected at replication time, not during a user's read. - Define merge rules in application code. Pick a deterministic strategy—timestamp-based last-write-wins or field-level merge—and apply it consistently.
- Delete losers explicitly. After writing the merged winner, issue one DELETE per losing
_rev. - Never parse
_revtokens. They are opaque strings. Always retrieve them from CouchDB responses.
Conflicts in CouchDB are not a failure mode; they are the database refusing to discard data without your consent. Treat them as a normal part of the write path, detect them early, and resolve them with rules your application owns.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.