CouchDB _rev in Practice: Preventing Lost Updates Without Locking
CouchDB uses immutable MVCC revisions and the _rev parameter to prevent lost updates. Learn the safe update flow, how to retry after a 409, and why replication conflicts require explicit resolution.
01 Dec 2025, 10:42 UTC

Two workers, one order, one lost update
Imagine two background workers processing the same order record in CouchDB. Worker A reads the document, sees status: "pending", and starts calculating a shipping quote. Worker B reads the same document a moment later, sees the same status, and marks it "shipped". Worker A finishes its calculation and writes back the document with status: "pending" and a new total field. The shipping update from Worker B is gone. This is the classic lost update problem, and in CouchDB it is not solved by row locks or transactions. It is solved by optimistic concurrency control built on the document _rev field.
MVCC and the _rev guard
CouchDB stores documents as a tree of immutable revisions using multi-version concurrency control (MVCC). When you create a document, CouchDB assigns a revision such as 1-706f7272696572. When you update it, the old revision remains in history and a new leaf revision is created, for example 2-9a8b7c6d5e. The _rev string is not a simple counter and should never be treated as a sort key or timestamp. It identifies a specific revision in that document's history.
To modify an existing document, your write must name the leaf revision you are replacing. A normal update looks like PUT /orders/order-123?rev=2-9a8b7c6d5e. If the rev parameter is missing or points to a revision that is no longer the current leaf, CouchDB returns 409 Document update conflict. That rejection is deliberate: it prevents Worker A above from silently overwriting Worker B's change.
The safe update loop and 409 retries
The safe pattern is read-modify-write with a retry loop, not a blind retry. Run these commands against your CouchDB instance, for example http://localhost:5984. You need a user with write access to the target database; the examples use an admin credential placeholder.
- Read the current document and capture its
_rev:curl -s "http://admin:password@localhost:5984/orders/order-123"Check that the response contains
_idand_rev, for example"2-9a8b7c6d5e". - Modify the JSON locally and PUT it back with the captured revision:
curl -X PUT "http://admin:password@localhost:5984/orders/order-123?rev=2-9a8b7c6d5e" \ -H "Content-Type: application/json" \ -d '{"status":"shipped","total":42.50}'On success, CouchDB returns a JSON body containing
"ok":trueand a newrevvalue such as3-.... That new revision is now the current leaf. - If another writer updated the document between your GET and PUT, CouchDB returns a body containing
"error":"conflict"and"reason":"Document update conflict."
Do not retry the same stale payload with the same old rev; it will fail again. Instead, re-read the document, recompute or merge your change against the new state, and retry with the new _rev. In a busy system, wrap this in a bounded retry loop with a small backoff. If your application ignores the 409 and assumes the write succeeded, you will lose data.
Worked example: two workers, one order
Suppose Worker A GETs order-123 at _rev=2-abc and Worker B GETs the same document at the same revision. Worker B PUTs first and receives _rev=3-def. Worker A now PUTs with rev=2-abc. CouchDB rejects it with 409. Worker A re-reads the document, sees Worker B's "status":"shipped", applies its own "total":42.50 change on top, and PUTs with rev=3-def. The final document contains both changes. No lock was held, and no update was lost.
Replication conflicts and tombstones
Optimistic concurrency protects a single CouchDB node, but replication introduces a harder problem. When two nodes accept writes to the same document while disconnected, replication can produce multiple leaf revisions. CouchDB deterministically picks a winner, but that winner is chosen by revision ordering rules, not by your business logic. The winning leaf may not be the one with the latest business timestamp.
Inspect the document's conflict state:
curl -s "http://admin:password@localhost:5984/orders/order-123?conflicts=true&revs_info=true"
Check for a _conflicts array listing the non-winning leaf revisions. You must fetch each conflicting revision and resolve them explicitly—merge the fields and write a new leaf, or delete the losing leaves with the correct conflicting _rev.
Deleting a conflicting revision is not a purge. A DELETE creates a tombstone leaf that replication uses to propagate the deletion. Tombstones remain in the revision history until compaction removes them, governed by _revs_limit. If you delete the wrong revision, you can lose data. Test conflict resolution in a scratch database first.
Operational limits and bulk writes
Two practical limits shape how you use _rev in production.
First, bulk writes apply documents independently. When you POST to /_bulk_docs, one conflict does not roll back the batch:
curl -X POST "http://admin:password@localhost:5984/orders/_bulk_docs" \
-H "Content-Type: application/json" \
-d '{"docs":[{"_id":"order-123","_rev":"2-abc","status":"shipped"},{"_id":"order-124","status":"pending"}]}'
The response is an array with one entry per document. A conflicted entry reports an error and no new rev; other entries still succeed. Do not use new_edits=false for ordinary application writes—that flag is for replication-style insertion of conflicting revisions.
Second, a successful PUT is not instantly visible everywhere. In a clustered CouchDB deployment, quorum settings (n, r, w) control when a write is considered durable and when other nodes observe it. Check GET / for the CouchDB version and, if clustered, the configured quorum values before making consistency assumptions.
Actionable closing
CouchDB's 409 is a feature, not a bug. Build your update path around GET, modify, PUT with ?rev=, and a bounded retry loop that re-reads on conflict. After replication, inspect _conflicts and resolve leaves explicitly rather than trusting the deterministic winner. Verify your behavior in a scratch database: force a 409 with a stale rev, confirm your retry logic merges correctly, and check that your conflict-resolution query matches your target CouchDB version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.