CouchDB validate_doc_update: Guarding Writes Without Middleware
CouchDB's validate_doc_update rejects bad revisions before they land, but it cannot read other documents. Here is where it fits and where it stops.
21 Sept 2026, 14:10 UTC

A write that no client-side check ever saw
Picture a CouchDB database backing a small publishing tool. The web form requires a title and a status of draft or published, so the application code feels safe. Then a migration script, a replication job, or a one-off curl command writes documents straight into the database. A document lands with no title. A view that emits doc.title now produces rows with null keys, and the bug surfaces days later as a rendering error rather than a rejected write.
CouchDB ships a hook for exactly this situation: validate_doc_update, a JavaScript function stored in a design document that runs before any revision is committed. It is enforced for every writer — application, script, or replication — and needs no extra infrastructure. It is also strictly limited, and knowing where those limits sit is what makes it useful rather than frustrating.
What runs, and when
The function lives in a design document such as _design/validation. CouchDB calls it for each document write against that database: single-document PUT and POST, bulk writes, and deletions. The signature is:
function(newDoc, oldDoc, userCtx, secObj)newDoc— the revision the client is trying to store.oldDoc— the currently stored revision, ornullfor a brand-new document.userCtx— the authenticated user'snameandroles.secObj— the database's security object (added in CouchDB 1.2; the four-argument form is standard on 2.x and 3.x).
To reject a write, throw an object. throw({forbidden: ...}) marks a rule violation and produces an HTTP 400; throw({unauthorized: ...}) signals a permission failure. Either way the revision is never created, and the response body carries error and reason fields you can surface to the caller.
The function runs in the same sandboxed JavaScript view server that powers views. It has no network access, no filesystem, and no way to read other documents. That keeps it deterministic and fast, and it is the reason some rules simply cannot live here.
Worked example: require a title, constrain a status
Run these commands from a shell that can reach a CouchDB 3.x instance. Writing a design document requires admin rights on the target database (a server admin, or a database admin). Set COUCH to your instance URL, for example http://admin:password@localhost:5984.
Put the function in a file so the nested quoting stays readable — validation.json:
{
"validate_doc_update": "function(newDoc, oldDoc, userCtx, secObj) { if (newDoc._deleted) { return; } if (typeof newDoc.title !== 'string' || newDoc.title.length === 0) { throw({forbidden: 'title is required'}); } if (newDoc.status && ['draft','published'].indexOf(newDoc.status) === -1) { throw({forbidden: 'status must be draft or published'}); } }"
}Upload it as a design document:
curl -X PUT "$COUCH/mydb/_design/validation" -H "Content-Type: application/json" --data-binary @validation.jsonExpected check: a 201 response containing ok, id, and rev. A 400 here means the file is not valid JSON or the user lacks admin rights — not that validation rejected something.
Now write two documents:
curl -X POST "$COUCH/mydb" -H "Content-Type: application/json" -d '{"title":"First post","status":"draft"}'
curl -X POST "$COUCH/mydb" -H "Content-Type: application/json" -d '{"status":"draft"}'Expect 201 with an id and rev for the first, and 400 with error: forbidden plus your reason string for the second. If both succeed, the design document was not saved where you think it was: confirm with curl "$COUCH/mydb/_design/validation" and check the function name is spelled exactly validate_doc_update.
The newDoc._deleted early return matters. Deletions go through the same function, and a tombstone has no title, so without that guard you would be unable to delete anything. It is a common first bug.
Where it stops, and what to do instead
Two limits cause most of the friction.
No cross-document lookups. You cannot check that author_id refers to a real document. Either denormalize the data you need into the document being written, or keep that rule in the application layer and treat validate_doc_update as a backstop for shape and roles.
No retroactive validation. Updating the design document changes behavior for future writes only. Documents already stored keep their invalid shape until they are written again. Find them with a Mango query:
curl -X POST "$COUCH/mydb/_find" -H "Content-Type: application/json" -d '{"selector":{"title":{"$exists":false}},"fields":["_id"]}'On a large database this may scan; add an index or run it as a one-off maintenance query rather than on every request.
| Layer | Catches | Misses |
|---|---|---|
| Client or form | Fast feedback for humans | Scripts, replication, direct HTTP |
| App middleware | Cross-document and external checks | Any writer that bypasses the app |
| validate_doc_update | Shape, types, allowed values, roles — for every writer | Lookups, external calls, already-stored documents |
One more operational risk: a design document is itself a document. A function that throws unconditionally, or a malformed upload, can block writes to the database until it is fixed. Test on a copy before pushing to production.
Verify, then keep the function small
Open Fauxton at /_utils, choose the database, open the design document, and confirm the function appears under validate_doc_update. Then repeat the two POSTs above and watch the status codes.
Overhead is real but small; measure it on your own workload rather than trusting a figure. Run a load tool such as wrk or ApacheBench against the same database with the design document present and then deleted, and compare write latency distributions. Keep the function to field presence, types, allowed values, and role checks — anything needing another document belongs in the application.
Rollback: curl -X DELETE "$COUCH/mydb/_design/validation" (admin required) removes the guard immediately. Existing documents are unaffected, and invalid writes become possible again, so fix the function rather than leaving it deleted.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.