CouchDB Interview Questions and Answers
Last updated:
Check out 35 of the most common CouchDB interview questions, then take an AI-powered practice interview
Q1How does the CouchDB document model with _id and _rev differ from a relational row?
BasicData Model
Answer
A CouchDB document is a self-contained JSON object identified by _id, which is any UTF-8 string you choose (the server will generate a UUID if you POST without one). There is no schema, no ALTER TABLE, and no join engine. The second reserved field, _rev, is what makes CouchDB different from most document stores: it is an MVCC token in the form generation-hash, for example 3-a1b2c3d4, where the generation counter increments on every write and the hash is derived deterministically from the document body, its attachment metadata, and the deleted flag.
Every write is a full document replacement. There is no partial UPDATE ... SET, so the normal cycle is read the document, mutate the JSON in your application, then PUT it back with the exact _rev you read.
If someone else wrote in between, your _rev is stale and CouchDB returns 409 Conflict instead of silently overwriting. Because the storage file is append-only, the previous revision body stays on disk until compaction runs, which is why a database that only ever receives updates still grows. Practically, this pushes you toward denormalisation and toward meaningful, sortable _id values such as order:2026-08-11:000123 or user:9f2a:profile, since _all_docs is a B-tree sorted by _id and prefix range scans over that tree are the cheapest query CouchDB can serve.
# Create a database (PUT, not POST)
curl -X PUT http://admin:secret@127.0.0.1:5984/orders
# Create a document with an explicit, sortable _id
curl -X POST http://admin:secret@127.0.0.1:5984/orders \
-H 'Content-Type: application/json' \
-d '{"_id":"order:2026-08-11:000123","customer":"acme","total":4999,"status":"new"}'
# {"ok":true,"id":"order:2026-08-11:000123","rev":"1-9c2f0e..."}
# Update = full replacement, and you must send the current _rev
curl -X PUT http://admin:secret@127.0.0.1:5984/orders/order:2026-08-11:000123 \
-H 'Content-Type: application/json' \
-d '{"_id":"order:2026-08-11:000123","_rev":"1-9c2f0e...",
"customer":"acme","total":5499,"status":"paid"}'
Key Points
- _id is any string; sortable prefixed ids make _all_docs range scans useful
- _rev is generation-hash and is required on every update and delete
- Writes replace the whole document; there is no partial field update
- Append-only storage keeps old revision bodies until compaction
Q2What does a 409 Conflict on a PUT actually mean, and how do you handle it correctly?
BasicMVCC
Answer
A 409 means the _rev you supplied is not the current winning revision of that document on the node handling your request. It is optimistic concurrency control: CouchDB never takes a lock, it just refuses the write when your view of the document is stale. The wrong reaction, and the one interviewers listen for, is to blindly GET the document, copy the new _rev, and re-PUT your own body.
That silently discards whoever wrote in between, which is a lost-update bug that will not show up in testing and will show up in a payments reconciliation later. The correct handling is to re-read the current document, re-apply your intended change to that fresh state (not to your stale copy), and retry, with a bounded retry count and jittered backoff so a hot document does not turn into a livelock. Better still, design so that concurrent writers do not target the same document: append an immutable event document per action and compute the current state in a view, instead of incrementing a counter field on one shared document.
Note that 409 also comes back when you PUT a database that already exists (that one is 412 Precondition Failed on database creation, so read the error body, which contains an error and reason pair such as {"error":"conflict","reason":"Document update conflict."}). In _bulk_docs the conflict is not an HTTP status at all: individual entries in the 201 response array carry their own error field.
// Correct read-modify-write retry loop with the nano client
async function applyDiscount(db, docId, pct, attempts = 5) {
for (let i = 0; i < attempts; i++) {
const doc = await db.get(docId); // always re-read fresh state
doc.total = Math.round(doc.total * (1 - pct));
doc.updatedAt = new Date().toISOString();
try {
return await db.insert(doc); // PUT with doc._rev
} catch (err) {
if (err.statusCode !== 409) throw err;
await new Promise(r => setTimeout(r, 50 * 2 ** i + Math.random() * 50));
}
}
throw new Error(`give up after ${attempts} conflicts on ${docId}`);
}
Key Points
- 409 = stale _rev, not a server error; it is the normal MVCC signal
- Re-read and re-apply the intent, never re-PUT your stale body
- Bounded retries with jitter; hot documents need a redesign, not more retries
- _bulk_docs reports per-document conflicts inside the response array
Q3When should you use _all_docs versus a MapReduce view?
BasicQuerying
Answer
_all_docs is the primary B-tree index that CouchDB maintains for free: every document keyed by _id, in ICU collation order. Any query whose selection criteria can be expressed as a range over _id should use it, because there is no secondary index to build, warm, or compact. Fetch a page with limit and skip (avoid large skip values, they are O(n)), or better, keyed pagination using startkey plus startkey_docid and limit+1.
Use include_docs=true to get bodies back, and the POST form with a keys array to fetch an arbitrary set of documents in one round trip. A MapReduce view is what you build when the query key is not the _id: total revenue by month, all orders for a customer, documents by status and timestamp. The view is a separate B-tree, written by running your JavaScript map function over every document in the shard and storing the emitted key/value pairs, and it costs disk and build time.
The rule interviewers want: exhaust _all_docs and a good _id scheme first, then Mango for ad-hoc filtering, then a view for a query you will run millions of times or that needs reduce aggregation. One more difference that catches people: _all_docs includes design documents (the _design/ prefix sorts before most application prefixes) and, with the right parameters, deleted tombstones, so filter accordingly.
# Prefix range scan over _all_docs, no secondary index needed
curl 'http://admin:secret@127.0.0.1:5984/orders/_all_docs?include_docs=true\
&startkey=%22order:2026-08-11%22&endkey=%22order:2026-08-11%5Cufff0%22&limit=50'
# Fetch a known set of ids in one request
curl -X POST http://admin:secret@127.0.0.1:5984/orders/_all_docs?include_docs=true \
-H 'Content-Type: application/json' \
-d '{"keys":["order:2026-08-11:000123","order:2026-08-11:000124"]}'
# Keyed pagination: carry the last key + id forward instead of using skip
curl 'http://admin:secret@127.0.0.1:5984/orders/_all_docs?limit=51\
&startkey=%22order:2026-08-11:000173%22&startkey_docid=order:2026-08-11:000173'
Key Points
- _all_docs is a free B-tree on _id in ICU collation order
- Use startkey/endkey range scans instead of building a view when possible
- Avoid skip for deep pages; use startkey + startkey_docid
- _all_docs also returns _design documents, so filter them out
Q4Write a map function and query the resulting view. What do the emitted key and value mean?
BasicViews
Answer
A map function is JavaScript stored inside a design document. CouchDB runs it once per document per index build, in an external query-server process, and every emit(key, value) call appends one row to the view's B-tree. The key controls ordering and lookup, and it can be a scalar or an array, which is how you build compound keys such as [customerId, timestamp].
The value is whatever you want returned alongside the key; keep it small, because it is copied into the index. If you need the whole document, do not emit it, emit null (or 1) and query with include_docs=true, which does a second lookup by _id at read time. Emitting the full document doubles your on-disk index size and makes every rebuild slower.
The map function must be a pure function of the single document passed in: no Date.now(), no Math.random(), no reference to other documents, because CouchDB caches results and only re-runs map when that document changes. Once the design document is saved, query it at /db/_design/<name>/_view/<viewname> with the usual parameters: key, keys, startkey, endkey, limit, skip, descending, include_docs, reduce=false. The first query after a write triggers indexing of everything that changed since the last query, so a cold view over a large database can block for minutes unless you use update=false or keep it warm.
// PUT /orders/_design/reporting
{
"_id": "_design/reporting",
"language": "javascript",
"views": {
"by_customer_date": {
"map": "function (doc) {\n if (doc.type !== 'order' || doc.status === 'draft') return;\n emit([doc.customer, doc.createdAt], doc.total);\n}"
}
}
}
# Every order for one customer, newest first
curl 'http://admin:secret@127.0.0.1:5984/orders/_design/reporting/_view/by_customer_date\
?startkey=["acme",{}]&endkey=["acme"]&descending=true&include_docs=true&limit=20'
Key Points
- emit(key, value) writes one row into the view B-tree
- Array keys give you compound and hierarchical lookups
- Emit null and use include_docs=true instead of emitting the document
- Map must be deterministic and depend only on the document argument
Q5What are the built-in reduce functions and how does group_level work?
BasicViews
Answer
CouchDB ships four native reducers that run inside Erlang rather than the JavaScript query server, so they are far faster than anything you write yourself: _count (number of rows), _sum (sums numeric values, and also sums arrays of numbers element-wise or numeric fields of an object), _stats (returns sum, count, min, max and sumsqr in one pass), and _approx_count_distinct (a HyperLogLog cardinality estimate). Use them whenever they fit; a custom JavaScript reduce is a last resort. Reduce results are stored in the inner nodes of the same B-tree as the map rows, which is why aggregation over millions of rows returns in milliseconds: CouchDB reads a handful of pre-computed inner nodes, not the leaves. group_level is how you pick the granularity when your key is an array.
With a key of [year, month, day], group_level=1 rolls up per year, group_level=2 per month, group_level=3 per day, and group=true is equivalent to grouping on the whole key. The default is no grouping at all, which collapses everything to one row. Two things trip people up in interviews: reduce=false is required when you want the raw map rows out of a view that has a reduce defined, and grouping only works left-to-right along the array key, so the field order in your emit determines every rollup you can ever ask for.
// _design/sales, view revenue
{
"views": {
"revenue": {
"map": "function (doc) {\n if (doc.type !== 'order' || doc.status !== 'paid') return;\n var d = new Date(doc.paidAt);\n emit([d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate()], doc.total);\n}",
"reduce": "_sum"
}
}
}
# Revenue per month for 2026
curl 'http://127.0.0.1:5984/orders/_design/sales/_view/revenue\
?group_level=2&startkey=[2026]&endkey=[2026,{}]'
# {"rows":[{"key":[2026,7],"value":184300},{"key":[2026,8],"value":92750}]}
# Raw rows from a view that has a reduce
curl '.../_view/revenue?reduce=false&limit=10'
Key Points
- _count, _sum, _stats, _approx_count_distinct run natively in Erlang
- Reduce values live in B-tree inner nodes, so rollups are near-instant
- group_level slices array keys left to right; key order fixes your rollups
- reduce=false is mandatory to read raw rows from a reduced view
Q6What is Mango (_find) and when do you prefer it over a MapReduce view?
BasicMango
Answer
Mango is CouchDB's declarative JSON query language, exposed at POST /db/_find, and it is what most teams now use for anything that is not a hot path. You send a selector object using operators like $eq, $gt, $gte, $lt, $lte, $ne, $in, $nin, $exists, $type, $regex, $size, $mod, $all, $elemMatch, $allMatch, plus the combinators $and, $or, $not and $nor, along with fields (a projection), sort, limit and skip. It reads like MongoDB's query language on purpose, which makes it the fastest thing for a new joiner to pick up.
The catch, and the thing interviewers check, is that Mango is not magic: it either uses a JSON index you created at POST /db/_index, or it falls back to scanning every document in the database and filtering in memory. When that happens the response includes a warning field saying no matching index found, and your p99 grows linearly with the database. Default limit for _find is 25, which quietly truncates results for people who never read the docs.
Prefer Mango for ad-hoc filters, admin screens, and queries whose shape changes with user input. Prefer a view when you need reduce-style aggregation, when the query runs constantly and you want a purpose-built B-tree, or when you need collation tricks that a selector cannot express.
curl -X POST http://admin:secret@127.0.0.1:5984/orders/_find \
-H 'Content-Type: application/json' -d '{
"selector": {
"type": "order",
"status": { "$in": ["paid", "shipped"] },
"total": { "$gte": 1000 },
"items": { "$elemMatch": { "sku": "GS-1", "qty": { "$gt": 1 } } }
},
"fields": ["_id", "customer", "total", "status"],
"sort": [{ "createdAt": "desc" }],
"limit": 100
}'
# Response may include:
# "warning": "No matching index found, create an index to optimize query time."
Key Points
- POST /db/_find with a selector; MongoDB-like operator set
- Without a matching index it does a full database scan
- Default limit is 25 unless you set it explicitly
- Views still win for aggregation and for very hot, fixed queries
Q7How do you create a Mango index and prove that a query is using it?
BasicMango
Answer
You create an index with POST /db/_index, giving it an index object with a fields array, a type (json is the standard B-tree index; text needs a search plugin), and optionally ddoc and name so you control which design document it lands in. A JSON index is really just a generated map/reduce view underneath, so it obeys the same rules: it is built lazily, it costs disk, and it has to be maintained on every write. Field order matters exactly like a composite index in Postgres or MySQL: an index on ["type","status","createdAt"] can serve a selector on type, or type plus status, or all three, but not one on status alone.
Sorting is the strictest part. To sort by a field, that field must be in an index, and the sort must match the index prefix, otherwise you get a 400 with the message that no index exists for this sort. To verify, POST the identical body to /db/_explain: the response tells you the chosen index (index.name, index.ddoc), whether it fell back to _all_docs (which means a full scan), and the actual start and end keys it will use. In interviews, being asked how you would debug a slow _find and answering _explain plus checking the warning field is the expected level.
# Create a composite JSON index in a named design doc
curl -X POST http://admin:secret@127.0.0.1:5984/orders/_index \
-H 'Content-Type: application/json' -d '{
"index": { "fields": ["type", "status", "createdAt"] },
"ddoc": "idx-orders", "name": "type-status-created", "type": "json"
}'
# Partial index: only index the rows you actually query
curl -X POST http://admin:secret@127.0.0.1:5984/orders/_index \
-H 'Content-Type: application/json' -d '{
"index": { "fields": ["createdAt"],
"partial_filter_selector": { "status": "pending" } },
"ddoc": "idx-pending", "type": "json"
}'
# Verify which index the planner picks
curl -X POST http://admin:secret@127.0.0.1:5984/orders/_explain \
-H 'Content-Type: application/json' \
-d '{"selector":{"type":"order","status":"paid"},"sort":[{"createdAt":"desc"}]}'
Key Points
- POST /db/_index with fields, type json, and an explicit ddoc/name
- Composite index prefixes work left to right, like SQL composite indexes
- sort fields must be covered by the index or the query 400s
- /db/_explain names the chosen index and exposes full-scan fallbacks
Q8What is a design document and what can it contain besides views?
BasicDesign Documents
Answer
A design document is an ordinary document whose _id starts with _design/, and it is the unit of deployment for server-side code. Its recognised fields are language (javascript by default, erlang if the native query server is enabled), views (an object of named map/reduce pairs), validate_doc_update (a single function that vets every write into the database), filters (named functions used by _changes and by replication), updates (update handlers that let a client POST a partial change and have the server construct the new document), and options such as partitioned. Older installations also carry shows, lists and rewrites, which are deprecated in the 3.x line and should not be used in new code; put presentation logic in your application tier.
Everything inside one design document forms a single index group: all the views in _design/reporting are built together, in one pass over the data, into one set of index files. That is the main design decision. Grouping views that change together is efficient, but it also means editing any one of them invalidates and rebuilds all of them.
Common practice is a small number of stable design documents plus separate ones for anything experimental. Because design documents are just documents, they replicate like data, they have a _rev, and they show up in _all_docs, which is how a code change accidentally propagates to every replica the moment you save it.
{
"_id": "_design/orders",
"language": "javascript",
"views": {
"by_status": { "map": "function (doc) { if (doc.type === 'order') emit(doc.status, null); }" },
"totals": { "map": "function (doc) { if (doc.type === 'order') emit(doc.status, doc.total); }",
"reduce": "_sum" }
},
"filters": {
"active": "function (doc, req) { return doc.type === 'order' && doc.status !== 'archived'; }"
},
"validate_doc_update": "function (newDoc, oldDoc, userCtx) {\n if (!newDoc._deleted && !newDoc.type) throw { forbidden: 'type is required' };\n}",
"options": { "partitioned": false }
}
Key Points
- _design/ prefix; contains views, filters, updates, validate_doc_update
- All views in one design document share a single index group and rebuild together
- shows, lists and rewrites are deprecated in 3.x; keep rendering in the app
- Design documents replicate like data and carry a _rev
Q9How does _bulk_docs behave, and what happens when one document in the batch fails?
BasicBulk Operations
Answer
POST /db/_bulk_docs with a docs array is the throughput tool: one HTTP round trip, one pass through the write path, instead of N separate PUTs. It handles creates, updates (include _id and _rev) and deletes (include _id, _rev and _deleted: true) in the same array. The critical property, and the standard interview trap, is that it is not a transaction.
CouchDB has no multi-document atomicity. The response is a 201 with an array in the same order as your input, and each entry independently reports either {"ok":true,"id":...,"rev":...} or {"id":...,"error":"conflict","reason":"Document update conflict."}. Some documents commit, others do not, and your client must walk the response array and re-drive the failures.
The old all_or_nothing:true option is gone in clustered CouchDB, so do not cite it. The other flag worth knowing is new_edits:false, which tells CouchDB to store the supplied _rev values verbatim rather than generating new ones. That is how the replicator writes revision history into a target, and it will happily create conflicting leaf revisions on purpose.
Never use it in application code unless you are implementing replication yourself. On sizing: batches of a few hundred to a couple of thousand small documents are usually the sweet spot, and you must stay under max_http_request_size, with individual documents under max_document_size (8 MB by default in 3.x).
curl -X POST http://admin:secret@127.0.0.1:5984/orders/_bulk_docs \
-H 'Content-Type: application/json' -d '{
"docs": [
{ "_id": "order:a", "type": "order", "total": 100 },
{ "_id": "order:b", "_rev": "2-stale", "type": "order", "total": 200 },
{ "_id": "order:c", "_rev": "3-abc", "_deleted": true }
]
}'
# HTTP 201, but inspect every element:
# [ {"ok":true,"id":"order:a","rev":"1-..."},
# {"id":"order:b","error":"conflict","reason":"Document update conflict."},
# {"ok":true,"id":"order:c","rev":"4-..."} ]
Key Points
- One request, per-document results; HTTP 201 does not mean everything saved
- No multi-document transactions; all_or_nothing was removed
- Deletes go in the same array with _deleted: true plus a valid _rev
- new_edits:false is replication machinery, not an application feature
Q10How do attachments work in CouchDB, and when should you avoid them?
BasicAttachments
Answer
An attachment is binary data stored alongside a document. You can inline it on write by putting base64 content into the _attachments object, or, far better, PUT it as a raw body to /db/docid/attname?rev=<current-rev> with the correct Content-Type. Reading is a plain GET on the same path, which streams the bytes with the stored content type and an ETag, so browsers cache it properly.
After the first write, GET on the document returns only a stub for each attachment: content_type, length, digest, revpos and stub:true, unless you pass attachments=true (base64 inline) or send Accept: multipart/related. Attachments are versioned with the document, so replacing a 2 MB file creates a new revision and the old bytes remain on disk until compaction. That is where the trouble starts.
Attachments inflate replication payloads (the whole file crosses the wire on every sync to every replica), they are not deduplicated, they make compaction slow and disk-hungry, and they push documents toward max_document_size. The pragmatic 2026 answer is that attachments are fine for small, document-intrinsic blobs such as a signature capture, a thumbnail, or a scanned Aadhaar-sized image in an offline-first field app where the attachment genuinely must sync with the record. For anything above a few hundred kilobytes, store the object in S3 or equivalent and keep only the key, checksum and content type in the document.
# Upload as a standalone attachment (streamed, no base64 overhead)
curl -X PUT 'http://admin:secret@127.0.0.1:5984/forms/visit:9912/signature.png?rev=3-a1b2' \
-H 'Content-Type: image/png' --data-binary @signature.png
# Read it back
curl -O 'http://admin:secret@127.0.0.1:5984/forms/visit:9912/signature.png'
# Document view afterwards shows a stub, not the bytes
# {"_id":"visit:9912","_rev":"4-c9d1",
# "_attachments":{"signature.png":{"content_type":"image/png",
# "revpos":4,"digest":"md5-9Kx...","length":48213,"stub":true}}}
# Delete an attachment (also creates a new revision)
curl -X DELETE 'http://admin:secret@127.0.0.1:5984/forms/visit:9912/signature.png?rev=4-c9d1'
Key Points
- Standalone PUT beats inline base64; base64 adds about 33% overhead
- Documents return attachment stubs by default; attachments=true inlines them
- Every attachment change makes a new revision and keeps the old bytes until compaction
- Large blobs belong in object storage with only the key stored in CouchDB
Q11How does authentication work in CouchDB, and what changed with the removal of admin party?
BasicSecurity
Answer
Up to the 2.x line, a fresh CouchDB with no admin configured ran in admin party mode, where every anonymous request had full admin rights. That is gone: from 3.0 onward the server refuses to start properly until an admin account exists, and single-node installs are expected to go through POST /_cluster_setup or to have an admin written into the [admins] section of local.ini, where the plaintext password is hashed on first startup. Beyond that, CouchDB supports HTTP Basic auth (simple, but it re-hashes the password with PBKDF2 on every single request, which is a real CPU cost on hot paths), cookie auth via POST /_session which returns an AuthSession cookie you then send on subsequent requests, proxy authentication where a trusted reverse proxy asserts identity through X-Auth-CouchDB-UserName and a signed token, and JWT bearer tokens configured under the [jwt_auth] section in recent 3.x releases, which is the cleanest option when an external identity provider already issues tokens.
Regular users live in the _users database as documents with the id org.couchdb.user:<name>, holding a name, a roles array, type:user and a password field that CouchDB replaces with a salted PBKDF2 hash on write. Per-database access is then controlled by the _security object. Two hardening settings worth naming in an interview: require_valid_user=true to reject anonymous requests entirely, and the fact that /_all_dbs is admin-only in 3.x.
# Create a user document in the _users database
curl -X PUT http://admin:secret@127.0.0.1:5984/_users/org.couchdb.user:asha \
-H 'Content-Type: application/json' \
-d '{"name":"asha","type":"user","roles":["field_agent"],"password":"initial-pass"}'
# Cookie auth: exchange credentials for an AuthSession cookie
curl -i -X POST http://127.0.0.1:5984/_session \
-H 'Content-Type: application/json' \
-d '{"name":"asha","password":"initial-pass"}'
# Set-Cookie: AuthSession=YXNoYTo2ODk...; Version=1; Path=/; HttpOnly
# Who am I / what roles do I have
curl -H 'Cookie: AuthSession=YXNoYTo2ODk...' http://127.0.0.1:5984/_session
Key Points
- Admin party removed in 3.0; an admin must exist before the server is usable
- Basic, cookie (_session), proxy and JWT auth are all supported
- Users are documents in _users with id org.couchdb.user:<name>
- Basic auth re-runs PBKDF2 per request; prefer cookies or JWT on hot paths
Q12Which CouchDB admin endpoints do you use day to day, and what is Fauxton?
BasicOperations
Answer
Fauxton is the bundled web UI served at /_utils on port 5984. It gives you database browsing, a document editor, a Mango query runner, a view editor with a preview, replication setup, and the active tasks and configuration screens. It is genuinely useful for exploration, but interviewers care more that you know the HTTP endpoints behind it, because that is what your monitoring and runbooks use.
GET / returns version and vendor. GET /_up is the load-balancer health check: 200 when the node is ready to serve, 404 when it is in maintenance mode. GET /_active_tasks is the single most useful operational endpoint, listing every running indexer, compaction and replication job with progress percentages and changes_done versus total_changes.
GET /_stats (or /_node/_local/_stats) exposes request counters, latency histograms and open-database counts, and recent 3.x builds also offer a Prometheus-format endpoint for scraping. GET /_membership shows the cluster's all_nodes and cluster_nodes lists, which is how you catch a node that never joined. GET /db returns database metadata including doc_count, doc_del_count (your tombstone count), update_seq, sizes.file versus sizes.active (the gap is compaction debt) and cluster q/n/r/w.
Since 3.0 the old node-local port 5986 is gone, so per-node configuration goes through /_node/_local/_config or /_node/<nodename>/_config. Knowing that one detail signals you have actually operated a 3.x cluster.
# Health check for the load balancer
curl -s http://127.0.0.1:5984/_up
# What is the server busy with right now?
curl -s http://admin:secret@127.0.0.1:5984/_active_tasks | jq '.[] |
{type, database, design_document, progress, changes_done, total_changes}'
# Compaction debt: file size vs live data size
curl -s http://admin:secret@127.0.0.1:5984/orders | jq '{doc_count, doc_del_count,
file: .sizes.file, active: .sizes.active, cluster}'
# Node-local config (port 5986 no longer exists in 3.x)
curl -s http://admin:secret@127.0.0.1:5984/_node/_local/_config/couchdb
Key Points
- /_utils is Fauxton; /_up is the readiness probe
- /_active_tasks shows indexer, compaction and replication progress
- sizes.file minus sizes.active is your compaction debt
- Port 5986 was removed in 3.0; use /_node/_local/_config instead
Q13What is the _changes feed and what are its feed modes?
BasicChanges Feed
Answer
GET /db/_changes is an ordered log of every document that has changed, and it is the foundation of both replication and every event-driven integration built on CouchDB. Each row carries the document id, a seq value, a changes array holding the leaf revisions, and a deleted flag on tombstones. Four feed modes matter. feed=normal returns everything since the given sequence and closes the connection. feed=longpoll holds the request open until at least one change arrives, then returns and closes, which is friendly to proxies and mobile networks. feed=continuous streams newline-delimited JSON on an open socket forever, and you must set heartbeat (for example heartbeat=10000) so CouchDB sends a blank line periodically and intermediate proxies do not kill an idle connection. feed=eventsource is the same stream in Server-Sent Events framing for browsers.
Useful parameters: since (a sequence, or now to skip history), include_docs=true to get bodies, limit, descending, timeout, and style=all_docs to see every conflicting leaf revision rather than just the winner. Two behaviours people get wrong: the feed is not a durable message queue with acknowledgements, so your consumer must persist the last processed sequence itself and be idempotent; and a document that is updated ten times appears once, at its latest sequence, not ten times. If you need every intermediate state, model those states as separate immutable documents.
# Continuous stream with heartbeat, resuming from a stored sequence
curl -N 'http://admin:secret@127.0.0.1:5984/orders/_changes\
?feed=continuous&heartbeat=10000&include_docs=true&since=42-g1AAAAI...'
# Only what happens from now on (skip all history)
curl 'http://127.0.0.1:5984/orders/_changes?feed=longpoll&since=now&timeout=60000'
# See conflicting leaf revisions, not just the winner
curl 'http://127.0.0.1:5984/orders/_changes?style=all_docs&limit=5'
Key Points
- Modes: normal, longpoll, continuous (needs heartbeat), eventsource
- since=now skips history; store the last seq yourself and be idempotent
- One row per document at its latest sequence, not one row per write
- style=all_docs exposes conflicting leaves; deleted:true marks tombstones
Q14How do you install and configure a single-node CouchDB 3.x instance for development?
BasicSetup
Answer
The standard route in 2026 is the official Docker image, which needs COUCHDB_USER and COUCHDB_PASSWORD supplied at first boot, otherwise the container starts in an unconfigured state. For a native install on Ubuntu, Debian or RHEL, the Apache project publishes packages, and the Debian postinst asks whether this is a standalone or clustered node and prompts for the admin password. After the container or service is up, a single node still needs the setup step: POST /_cluster_setup with action single_node, which creates the three internal system databases (_users, _replicator and, in older versions, _global_changes) and marks the node as configured.
Skipping it is the number one reason a brand-new instance returns odd errors on replication. Configuration is layered: default.ini ships with the distribution and must not be edited, local.ini (or a file dropped into local.d/) holds your overrides, and anything written through the HTTP config API lands in local.ini too. The settings you almost always touch are [chttpd] bind_address = 0.0.0.0 so the port is reachable, [couchdb] single_node = true for standalone deployments, [couchdb] max_document_size, [cluster] q and n, and [chttpd] require_valid_user = true. Never expose 5984 straight to the internet: terminate TLS on nginx or a load balancer and let CouchDB listen on the private interface.
# docker-compose.yml
services:
couchdb:
image: couchdb:3
environment:
COUCHDB_USER: admin
COUCHDB_PASSWORD: secret
ports: ["5984:5984"]
volumes: ["couch-data:/opt/couchdb/data",
"./local.d:/opt/couchdb/etc/local.d"]
volumes: { couch-data: {} }
# Finish single-node setup (creates _users and _replicator)
curl -X POST http://admin:secret@127.0.0.1:5984/_cluster_setup \
-H 'Content-Type: application/json' \
-d '{"action":"single_node","bind_address":"0.0.0.0",
"username":"admin","password":"secret","port":5984}'
# Read or change config over HTTP (persists into local.ini)
curl -X PUT http://admin:secret@127.0.0.1:5984/_node/_local/_config/chttpd/require_valid_user \
-H 'Content-Type: application/json' -d '"true"'
Key Points
- Docker image requires COUCHDB_USER and COUCHDB_PASSWORD on first boot
- POST /_cluster_setup with single_node creates _users and _replicator
- Edit local.ini or local.d/*.ini, never default.ini
- Terminate TLS upstream; do not expose 5984 publicly
Q15Explain CouchDB's view collation order and how it affects startkey and endkey queries.
IntermediateViews
Answer
View rows are stored sorted by key using a fixed collation specification, and knowing the order is what makes range queries work. Across types the order is: null, then false, then true, then numbers, then strings, then arrays, then objects. Strings are compared with ICU Unicode collation, not raw byte order, so lowercase and uppercase interleave (a sorts before B) and locale-aware rules apply, which surprises people who expect ASCII ordering.
Arrays compare element by element, and a shorter array sorts before a longer one that shares its prefix, which is precisely why ["acme"] is a valid lower bound for every key beginning with acme. Three practical consequences come up constantly. First, prefix search: to fetch everything under a string prefix, set startkey to the prefix and endkey to the prefix plus the high code point \ufff0, or for array keys use an empty object {} as the upper sentinel, since objects sort after everything else.
Second, descending=true reverses the traversal, so you must swap startkey and endkey, and forgetting that returns an empty result set with no error, which is a favourite interview trick. Third, inclusive_end defaults to true, so endkey itself is included; set it to false when you page by using the previous page's last key as the next page's boundary. Numbers and numeric strings do not collate together, so "10" sorts before "9" while 10 sorts after 9.
# Collation order, low to high:
# null < false < true < numbers < strings (ICU) < arrays < objects
# String prefix scan
?startkey="order:2026-08"&endkey="order:2026-08\ufff0"
# Array prefix scan: {} is the universal high sentinel
?startkey=["acme"]&endkey=["acme",{}]
# Newest first: swap the bounds when descending
?descending=true&startkey=["acme",{}]&endkey=["acme"]
# Exclusive upper bound for keyset pagination
?startkey=["acme","2026-08-01"]&endkey=["acme","2026-09-01"]&inclusive_end=false
Key Points
- Type order: null, booleans, numbers, strings, arrays, objects
- Strings use ICU collation, so it is not ASCII byte order
- descending=true requires swapping startkey and endkey
- Use \ufff0 for string prefixes and {} for array prefixes
Q16Why does editing a design document rebuild every view in it, and how do you deploy view changes with zero downtime?
IntermediateViews
Answer
CouchDB identifies a view index by a signature computed from the design document's view definitions (the actual map and reduce source text, plus options such as the collation and the language). Change a single character in one map function and the signature changes, which means the entire index group for that design document is a different index as far as the server is concerned. The old index files stay on disk, orphaned, and the next query has to build the new ones from scratch by streaming every document in every shard through the query server.
On a database with tens of millions of documents that is minutes to hours, and it happens on the first user request after you deploy, which is how a routine change becomes an outage. The safe pattern is versioned design documents plus a warm-and-swap. Write the new definition to _design/orders-v7, query it once with a trivial request to trigger indexing, poll /_active_tasks until changes_done equals total_changes for that design_document, and only then point the application at the new name.
Keep the old design document until the switch is verified, then delete it and run POST /db/_view_cleanup to reclaim the orphaned index files, which compaction alone does not remove. Alternatives include a staged rollout behind a config flag, the _index_ warming that some teams script into their deploy pipeline, and simply avoiding cosmetic edits (reformatting a map function costs a full rebuild even though behaviour is unchanged).
# 1. Publish the new version under a new name
curl -X PUT http://admin:secret@127.0.0.1:5984/orders/_design/reporting-v7 \
-H 'Content-Type: application/json' -d @reporting-v7.json
# 2. Trigger the build (returns after indexing on a cold view)
curl 'http://127.0.0.1:5984/orders/_design/reporting-v7/_view/by_customer_date?limit=1' &
# 3. Watch progress until it is warm
watch -n5 "curl -s http://admin:secret@127.0.0.1:5984/_active_tasks \
| jq '.[] | select(.type==\"indexer\") | {design_document, changes_done, total_changes}'"
# 4. Flip the app to reporting-v7, then remove the old ddoc and reclaim disk
curl -X DELETE 'http://admin:secret@127.0.0.1:5984/orders/_design/reporting?rev=12-ab'
curl -X POST http://admin:secret@127.0.0.1:5984/orders/_view_cleanup
Key Points
- The index signature covers the whole design document, so one edit rebuilds all its views
- Cold rebuilds happen on the first user query, in the request path
- Versioned ddoc names plus warm-then-swap is the zero-downtime pattern
- _view_cleanup deletes orphaned index files; compaction does not
Q17How does the rereduce parameter work in a custom reduce function, and what causes reduce_overflow_error?
IntermediateViews
Answer
A custom reduce has the signature function(keys, values, rereduce). CouchDB calls it twice in different modes. In the first pass, rereduce is false, keys is an array of [key, docid] pairs and values holds the emitted values for a leaf B-tree node.
In the second and later passes, rereduce is true, keys is null, and values is an array of previous reduce outputs that must be combined further up the tree. Your function therefore has to be associative and commutative, and it must accept its own output as input. A reduce that returns a differently shaped object in each mode breaks silently at scale, because the bug only appears once the tree is deep enough to require re-reduction, so it passes on a hundred test documents and fails on a million.
The second rule is that the reduce output must shrink relative to its input. CouchDB checks this and aborts with reduce_overflow_error, whose message says that the reduce output must shrink more rapidly, when your function returns something that grows with the number of rows, the classic case being a reduce that collects values into an array or builds a list of unique ids. If you need distinct counts, use _approx_count_distinct; if you need lists, that is a job for the map rows with group-by handling in the application, not for reduce. And before writing any of this, check whether _sum, _count or _stats already does the job in Erlang.
// Valid: fixed-size output, handles both modes
function (keys, values, rereduce) {
var acc = { count: 0, total: 0, max: -Infinity };
if (rereduce) {
values.forEach(function (v) {
acc.count += v.count;
acc.total += v.total;
acc.max = Math.max(acc.max, v.max);
});
} else {
values.forEach(function (v) {
acc.count += 1;
acc.total += v;
acc.max = Math.max(acc.max, v);
});
}
return acc;
}
// Invalid: output grows with input -> reduce_overflow_error
// function (keys, values, rereduce) {
// return rereduce ? [].concat.apply([], values) : values;
// }
Key Points
- rereduce=true means values are your own previous outputs; keys is null
- The function must be associative and shape-stable across both modes
- Output must shrink or CouchDB raises reduce_overflow_error
- Never accumulate arrays or id lists inside reduce
Q18Walk through the CouchDB replication protocol step by step.
IntermediateReplication
Answer
Replication is plain HTTP, one-way, and driven entirely by the replicator, which can run inside either server or inside a client such as PouchDB. The sequence is: GET the source and target database info to confirm both exist and read their update_seq values. Compute a deterministic replication id from source, target, filter and options, then GET /_local/<replication-id> from both sides to read the last checkpoint, and take the lowest common recorded sequence as the starting point.
Pull /source/_changes?since=<checkpoint>&style=all_docs in batches, which yields document ids and all their leaf revisions. Send that id-to-revisions map to POST /target/_revs_diff, and the target answers with only the revisions it is missing, which is what makes re-syncing a mostly-current replica cheap. Fetch those revisions from the source with POST /source/_bulk_get (or GET with open_revs and latest=true on older peers), including attachments.
Write them into the target with POST /target/_bulk_docs?new_edits=false, which preserves the original _rev values and the revision ancestry instead of generating fresh ones. Finally, PUT an updated _local/<replication-id> checkpoint document on both sides so an interrupted run resumes rather than restarts. Two consequences interviewers probe: because revision trees are copied verbatim, replication can legitimately create conflicts in the target, and because _local documents are never themselves replicated, checkpoints stay private to each pair. Bidirectional sync is just two independent one-way replications.
# The wire conversation, simplified
GET /source/ -> {"update_seq":"9873-g1AA...", ...}
GET /target/_local/<replid> -> {"source_last_seq":"9012-g1AA..."}
GET /source/_changes?since=9012-g1AA&style=all_docs&limit=500
POST /target/_revs_diff
{"order:a":["3-cf1","3-ab9"],"order:b":["7-119"]}
-> {"order:a":{"missing":["3-ab9"]}} # target already has 3-cf1
POST /source/_bulk_get
{"docs":[{"id":"order:a","rev":"3-ab9"}]}
POST /target/_bulk_docs?new_edits=false
{"docs":[{"_id":"order:a","_rev":"3-ab9","_revisions":{...}, ...}]}
PUT /target/_local/<replid> {"source_last_seq":"9873-g1AA..."}
PUT /source/_local/<replid> {"source_last_seq":"9873-g1AA..."}
Key Points
- _revs_diff makes incremental sync cheap by asking what the target lacks
- _bulk_docs with new_edits=false preserves revision history verbatim
- _local checkpoint documents make replication resumable and are never replicated
- Replication can create conflicts by design; two-way sync is two one-way jobs
Q19How do you configure continuous replication with the _replicator database and monitor it in production?
IntermediateReplication
Answer
POST /_replicate is the fire-and-forget form: it runs inside the request, dies with a server restart, and should only be used for one-off migrations. Anything permanent goes into the _replicator database as a document with source, target, continuous:true and optionally create_target, selector, filter, doc_ids and since_seq. Because it is a document, the job survives restarts, replicates to other nodes if you replicate _replicator itself, and can be edited or deleted like any data.
Credentials go in the auth object rather than embedded in a URL (URL-embedded passwords end up in logs, which will get flagged in any security review), and in a cluster you should not run the same replication document on multiple nodes without understanding that the scheduler elects one owner per job. Monitoring is where candidates usually stop too early. The replication document itself gets _replication_state (triggered, running, completed, failed, crashing) written back into it, but the real observability lives in the scheduler endpoints introduced in the 2.x line: GET /_scheduler/jobs shows every running job with its history, its current state, and per-job counters such as docs_written, docs_read, doc_write_failures and missing_revisions_found, while GET /_scheduler/docs maps each _replicator document to its scheduler state and last error. A job in the crashing state is retrying with exponential backoff, and the info field carries the actual reason, most often unauthorized, db_not_found, or a TLS failure.
# Persistent, restart-safe continuous replication
curl -X PUT http://admin:secret@127.0.0.1:5984/_replicator/orders-to-dr \
-H 'Content-Type: application/json' -d '{
"_id": "orders-to-dr",
"source": { "url": "http://primary.internal:5984/orders",
"auth": { "basic": { "username": "repl", "password": "..." } } },
"target": { "url": "http://dr.internal:5984/orders",
"auth": { "basic": { "username": "repl", "password": "..." } } },
"continuous": true,
"create_target": false,
"selector": { "type": { "$ne": "audit_log" } }
}'
# Monitor
curl -s http://admin:secret@127.0.0.1:5984/_scheduler/jobs \
| jq '.jobs[] | {id, state: .history[0].type, docs_written, doc_write_failures, info}'
curl -s http://admin:secret@127.0.0.1:5984/_scheduler/docs | jq '.docs[] | {id, state, error_count, info}'
Key Points
- _replicator documents survive restarts; POST /_replicate does not
- Put credentials in the auth object, never inside the URL
- /_scheduler/jobs and /_scheduler/docs are the real monitoring surface
- crashing state means exponential backoff; read info for the cause
Q20Compare JavaScript filter functions, selector-based filtering and doc_ids for filtered replication.
IntermediateReplication
Answer
Filtered replication decides which documents cross the wire, and the mechanism you choose has a large performance impact. A JavaScript filter is a named function in a design document, function(doc, req), referenced as filter:"ddoc/name". It is the most expressive option (you get the request object, including req.query parameters and req.userCtx) but the slowest by a wide margin: every candidate document is serialised out of Erlang, shipped to a couchjs query-server process, evaluated, and shipped back.
On a busy source this pins CPU and becomes the bottleneck for the whole cluster, not just that replication. A selector filter uses the same Mango selector syntax in the replication document, and it is evaluated natively inside Erlang with no query-server round trip, which is typically several times faster. It is the default recommendation for anything expressible as a field match, and it can also be pushed into _changes directly with filter=_selector. doc_ids is a fixed array of document ids and is the cheapest of all, but only useful when the set is known and small.
There is also filter=_design to replicate only design documents, handy for pushing view code to edge nodes without the data. Two gotchas: changing a filter changes the replication id, so the job restarts from sequence zero, re-scanning the whole source; and filtering saves bandwidth but not source-side scan cost, since the source still walks every change.
// Slow: JS filter, one query-server round trip per candidate document
{
"_id": "_design/sync",
"filters": {
"by_region": "function (doc, req) {\n return doc.type === 'visit' && doc.region === req.query.region;\n}"
}
}
// replication doc: { "filter": "sync/by_region", "query_params": { "region": "north" } }
// Fast: native selector, evaluated in Erlang
{
"_id": "visits-north",
"source": "http://primary:5984/visits",
"target": "http://edge-north:5984/visits",
"continuous": true,
"selector": { "type": "visit", "region": "north", "archived": { "$ne": true } }
}
// Cheapest: an explicit, known set
// { "doc_ids": ["config:north", "pricing:2026-08"] }
Key Points
- JS filters run in couchjs per document and are the slowest option
- selector filters run natively in Erlang; prefer them by default
- doc_ids is cheapest for small known sets; filter=_design ships only view code
- Changing the filter changes the replication id and restarts from zero
Q21What is validate_doc_update and what are its security and performance implications?
IntermediateSecurity
Answer
validate_doc_update, usually shortened to VDU, is a single function per design document with the signature function(newDoc, oldDoc, userCtx, secObj). CouchDB calls it for every write into that database, from every source, including replication, and rejects the write if the function throws. Throw {forbidden: "reason"} for a 403 and {unauthorized: "reason"} for a 401; returning normally accepts the write. userCtx gives you name, roles and the database name, and secObj gives you the _security document, so you can express rules like only the owner may edit this record, the status field may only move forward through the workflow, immutable fields may never change once set, and required fields must be present.
This is the only server-side write validation CouchDB has, which makes it the security boundary for the CouchApp and offline-first patterns where mobile clients replicate directly against the server with no API tier in between. Three things to say in an interview. First, it also runs during replication, so a VDU added later can cause incoming replication to fail for documents that were legal when they were written, and those failures show up as doc_write_failures rather than as an obvious error.
Second, it must handle deletes: a deletion arrives as newDoc._deleted === true with an otherwise empty body, so any check for a required field must skip that case. Third, it runs in the JavaScript query server on every single write, including every document of a _bulk_docs batch, so a heavy VDU directly caps your write throughput. Multiple design documents each with a VDU means all of them run, and all must pass.
function (newDoc, oldDoc, userCtx, secObj) {
function forbid(msg) { throw { forbidden: msg }; }
// Deletions carry no body: only check ownership
if (newDoc._deleted) {
if (oldDoc && oldDoc.owner !== userCtx.name &&
userCtx.roles.indexOf('_admin') === -1) forbid('not your document');
return;
}
if (!newDoc.type) forbid('type is required');
if (newDoc.type === 'visit') {
if (typeof newDoc.agent !== 'string') forbid('agent must be a string');
if (oldDoc && oldDoc.agent !== newDoc.agent) forbid('agent is immutable');
var flow = ['draft', 'submitted', 'approved'];
if (oldDoc && flow.indexOf(newDoc.status) < flow.indexOf(oldDoc.status)) {
forbid('status cannot move backwards');
}
}
if (userCtx.roles.indexOf('field_agent') === -1 &&
userCtx.roles.indexOf('_admin') === -1) {
throw { unauthorized: 'field_agent role required' };
}
}
Key Points
- Runs on every write including replication; throw forbidden (403) or unauthorized (401)
- userCtx and secObj give you the identity and the database security object
- Must handle _deleted:true deletions explicitly
- Executes in couchjs per document, so it directly limits write throughput
Q22How does the _security object work, and what is the database-per-user pattern?
IntermediateSecurity
Answer
Every database has a _security document, read and written at GET and PUT /db/_security. It holds two objects, admins and members, each with a names array and a roles array. Database admins can edit design documents, change the security object itself, and trigger compaction.
Members can read and write ordinary documents. If members is empty, the database is readable by any authenticated user, which is a very common accidental exposure. Server admins (the ones in the [admins] config section, holding the _admin role) bypass everything.
The important limitation, and the one interviewers want you to state plainly, is that CouchDB has no per-document read permission. Access control is per database for reads, and only writes can be filtered further through validate_doc_update. You cannot let two users share a database while hiding each other's rows, because views, _all_docs and _changes all operate over the whole database.
That constraint is what produces the database-per-user pattern: give every user their own database, replicate the slice they need into it, and let them sync freely. CouchDB even automates the creation part with the couch_peruser feature, which creates userdb-<hex-encoded-name> automatically for each entry in _users and sets the security object so only that user is a member. It solves authorisation cleanly, but it converts one database into thousands, and the operational cost of that (file descriptors, open-database limits, compaction fan-out, per-database replication jobs) is the real subject of the follow-up question.
# Lock a database to specific roles
curl -X PUT http://admin:secret@127.0.0.1:5984/orders/_security \
-H 'Content-Type: application/json' -d '{
"admins": { "names": ["asha"], "roles": ["ops_lead"] },
"members": { "names": [], "roles": ["field_agent", "backoffice"] }
}'
# Enable database-per-user (creates userdb-<hex> for every _users entry)
curl -X PUT http://admin:secret@127.0.0.1:5984/_node/_local/_config/couch_peruser/enable \
-H 'Content-Type: application/json' -d '"true"'
curl -X PUT http://admin:secret@127.0.0.1:5984/_node/_local/_config/couch_peruser/delete_dbs \
-H 'Content-Type: application/json' -d '"false"'
# Verify what the current credentials can actually do
curl -s http://asha:pass@127.0.0.1:5984/_session | jq '.userCtx'
Key Points
- _security has admins and members, each with names and roles
- Empty members means every authenticated user can read the database
- No per-document read permission exists; reads are per database
- couch_peruser automates database-per-user but multiplies operational cost
Q23How do q, n, r and w work in a CouchDB cluster, and what does a 202 Accepted on a write mean?
IntermediateClustering
Answer
When a database is created on a cluster it is split into q shard ranges, and each shard is stored n times on different nodes. Both values are fixed at creation time (PUT /db?q=8&n=3) and default from the [cluster] section of the config, typically q=2 and n=3, with n silently capped at the number of nodes, so a single-node install ends up with n=1. Every document belongs to exactly one shard, chosen by hashing its _id, so it lives on exactly n nodes.
Any node can serve any request: it acts as coordinator, fans the operation out to the replicas, and applies a quorum, r for reads and w for writes, both defaulting to (n+1)/2, which is 2 of 3. You can override them per request with ?r=1 for a faster, possibly stale read or ?w=3 to wait for every copy. The status codes matter.
A write that reaches at least one replica but not w replicas returns 202 Accepted, not 201 Created, meaning the data is stored somewhere and internal replication will repair the rest, but nothing is confirmed yet. Client libraries that check only for a 2xx treat that as success, which is how teams end up surprised after a node flaps. If too few replicas answer at all you get a 500 carrying {"error":"nodedown","reason":"progress not possible"}. For diagnosis, GET /db/_shards lists the ranges and the nodes holding them, GET /db/_shards/<docid> tells you exactly where one document lives, and GET /_membership compares all_nodes (in the shard map) against cluster_nodes (actually connected), which is how you catch a node that never rejoined after a restart.
# Shard and replica layout is fixed at database creation
curl -X PUT 'http://admin:secret@node1:5984/orders?q=8&n=3'
# Where do the shards live, and where does one document live?
curl -s http://admin:secret@node1:5984/orders/_shards | jq '.shards | keys'
curl -s http://admin:secret@node1:5984/orders/_shards/order:2026-08-11:000123
# Cluster membership: all_nodes (in the shard map) vs cluster_nodes (connected)
curl -s http://admin:secret@node1:5984/_membership
# Per-request quorum overrides
curl 'http://admin:secret@node1:5984/orders/order:a?r=1' # fast, may be stale
curl -X POST 'http://admin:secret@node1:5984/orders?w=3' \
-H 'Content-Type: application/json' -d '{"type":"order","total":100}'
# 202 Accepted -> stored on >=1 node, quorum w not met, repaired in background
# 500 {"error":"nodedown","reason":"progress not possible"} -> too few replicas
Key Points
- q shards and n replicas are set at creation and cannot be changed by editing config
- Document placement is a hash of _id; any node can coordinate any request
- r and w default to (n+1)/2; override per request with ?r= and ?w=
- 202 Accepted means quorum was not met, so do not treat it as a confirmed write
Q24A CouchDB database file keeps growing while doc_count stays flat. What is happening and how do you fix it?
IntermediateStorage
Answer
CouchDB storage is append-only. Every update writes a new document body and new B-tree nodes to the end of the shard file, and the previous versions are simply no longer referenced. Deletes are worse than people expect: a delete is an ordinary write of a new revision carrying _deleted:true, which increments doc_del_count while freeing nothing.
So a database that only receives updates and deletes grows forever until compaction reclaims the dead bytes. Diagnose it with GET /db and compare sizes.file against sizes.active; the difference is your compaction debt, and a file three or four times the active size on a busy database is normal just before compaction runs. Fix it with POST /db/_compact, which rewrites each shard into a fresh file and needs free disk roughly equal to sizes.active while it runs, POST /db/_compact/<ddocname> for a view index group, and POST /db/_view_cleanup to delete index files orphaned by a changed design document, which compaction does not touch.
In 3.x the smoosh auto-compactor normally handles this, driven by [smoosh] channel settings such as db_ratio, view_ratio and min_size, and disabling smoosh to "save IO" is a classic self-inflicted outage. What compaction will never remove is the tombstone itself: the _id, _rev and deleted flag of every deleted document are retained permanently, because replication needs them to propagate the delete to peers. That is why using a CouchDB database as a work queue eventually leaves millions of tombstones dragging on every _all_docs and _changes scan. The only real cures are POST /db/_purge, which rewrites revision history and invalidates replication checkpoints and view indexes, or database rotation: write to orders-2026-08, archive it by replication, then DELETE the whole database.
# Compaction debt: file bytes vs live bytes, plus tombstone count
curl -s http://admin:secret@127.0.0.1:5984/orders \
| jq '{doc_count, doc_del_count, file: .sizes.file, active: .sizes.active}'
# Compact data, then the view index group, then orphaned index files
curl -X POST -H 'Content-Type: application/json' \
http://admin:secret@127.0.0.1:5984/orders/_compact
curl -X POST -H 'Content-Type: application/json' \
http://admin:secret@127.0.0.1:5984/orders/_compact/reporting
curl -X POST http://admin:secret@127.0.0.1:5984/orders/_view_cleanup
# Track progress
curl -s http://admin:secret@127.0.0.1:5984/_active_tasks \
| jq '.[] | select(.type | test("compaction")) | {type, database, progress}'
# Prune the revision tree (default 1000 revisions kept per document)
curl -X PUT -H 'Content-Type: application/json' \
http://admin:secret@127.0.0.1:5984/orders/_revs_limit -d '100'
# Last resort: purge tombstones and break every existing replication checkpoint
curl -X POST http://admin:secret@127.0.0.1:5984/orders/_purge \
-H 'Content-Type: application/json' -d '{"order:zzz":["3-ab12"]}'
Key Points
- Append-only file plus tombstones means deletes never shrink the database
- sizes.file minus sizes.active is compaction debt; smoosh normally handles it
- _view_cleanup removes orphaned index files that compaction leaves behind
- _purge invalidates replication checkpoints; prefer rotating databases instead
Q25How do you find document conflicts created by replication, and what does correct resolution look like?
IntermediateConflicts
Answer
After replication a document can hold several leaf revisions in its revision tree. CouchDB never merges them, because it has no idea what your data means. Instead it picks a deterministic winner so that every replica independently agrees: the leaf with the highest generation number wins, and ties are broken by comparing the revision hash strings, with the higher one winning.
That is arbitrary but consistent, which is the point. The losing leaves are not deleted; they sit in the tree and their bodies stay on disk until compaction, so a plain GET looks completely normal and the conflict is invisible until you ask for it. You surface them with GET /db/docid?conflicts=true, which adds a _conflicts array of losing revision ids, with ?open_revs=all to fetch every leaf body, or with _changes?style=all_docs.
To sweep an entire database, either run a view whose map emits when doc._conflicts is present, or use a Mango selector on {"_conflicts": {"$exists": true}}. Resolution is application logic and there is no way around that. Read all the leaves, apply a domain rule (last write wins on a server-stamped timestamp is the cheap answer, field-level merge or recomputing from immutable event documents is the right one), then in a single _bulk_docs write the merged body as a new revision of the winning branch and explicitly delete each losing leaf with {_id, _rev: loser, _deleted: true}.
Deleting the losers is the step candidates forget. Skip it and the document stays conflicted forever, the old branch resurfaces the next time the winner is updated, and your _conflicts monitoring view keeps firing. In an offline-first deployment, treat conflict count as a first-class metric, not an exception path.
// Fetch every leaf, merge, and tombstone the losers in one round trip
async function resolve(db, docId) {
const leaves = await db.get(docId, { open_revs: "all" });
const docs = leaves.filter(r => r.ok).map(r => r.ok);
if (docs.length < 2) return;
// Domain rule: newest server-stamped write wins, but keep every line item
const winner = docs.reduce((a, b) =>
a.serverUpdatedAt > b.serverUpdatedAt ? a : b);
const merged = { ...winner, items: dedupeBySku(docs.flatMap(d => d.items || [])) };
const batch = [merged];
for (const d of docs) {
if (d._rev !== winner._rev) {
batch.push({ _id: d._id, _rev: d._rev, _deleted: true });
}
}
await db.bulk({ docs: batch });
}
// Sweep the database for anything still conflicted
// POST /orders/_find
// {"selector":{"_conflicts":{"$exists":true}},"conflicts":true,"limit":100}
Key Points
- Winner is deterministic: highest generation, then highest revision hash
- Losing leaves persist silently; ?conflicts=true or style=all_docs reveals them
- Resolution must delete the losing revisions, not just write a merged body
- Track conflict count as a metric in any offline-first deployment
Q26What is the couchjs query server, and which config keys and errors involve it?
IntermediateInternals
Answer
CouchDB is written in Erlang and cannot execute JavaScript itself, so every map function, custom reduce, replication filter, update handler and validate_doc_update runs in an external OS process: couchjs, a small SpiderMonkey host binary shipped with the distribution. Erlang talks to it over stdin and stdout with a line-delimited JSON protocol whose commands are literally ["add_fun", source], ["map_doc", doc] and ["reduce", funs, kvs]. Everything you can tune lives in the [query_server_config] and [couchdb] config sections: os_process_limit caps how many couchjs processes may exist (100 by default in 3.x), os_process_soft_limit controls how many stay idle in the pool, os_process_timeout (5000 ms by default) kills a process that has not replied, and reduce_limit = true is what enforces the shrinking-output rule that produces reduce_overflow_error.
The failures show up in couchdb.log as os_process_error with an exit_status, which almost always means a map function threw on an unexpected document, or as an OS process timed out message, which usually means someone looped over a large array inside a map. Because each couchjs is a real OS process with its own heap, raising os_process_limit on a box that already has many design documents is an efficient way to get the Erlang VM OOM-killed. The two ways to avoid the query server entirely are worth naming in an interview: use the built-in Erlang reducers (_sum, _count, _stats, _approx_count_distinct) so no JavaScript executes during aggregation, and use Mango selector filters instead of JavaScript filters for replication. The packaged SpiderMonkey in current 3.x builds is modern enough for let, const and arrow functions, but distributions differ, so keep map source conservative.
# Inspect the query server pool
curl -s http://admin:secret@127.0.0.1:5984/_node/_local/_config/query_server_config
# {"reduce_limit":"true","os_process_limit":"100","os_process_soft_limit":"100"}
# Give a slow indexer more headroom (restart-safe, written to local.ini)
curl -X PUT -H 'Content-Type: application/json' \
http://admin:secret@127.0.0.1:5984/_node/_local/_config/couchdb/os_process_timeout \
-d '"10000"'
# What the failures look like in couchdb.log
# [error] OS Process Error <0.123.0> :: {exit_status,1}
# {"error":"os_process_error","reason":"OS process timed out."}
# {"error":"reduce_overflow_error","reason":"Reduce output must shrink more rapidly"}
# How many query-server processes are actually alive on this box
pgrep -fc couchjs
Key Points
- All JavaScript runs in external couchjs processes over a stdio JSON protocol
- os_process_limit, os_process_soft_limit, os_process_timeout and reduce_limit are the knobs
- os_process_error usually means a map function threw on a malformed document
- Native Erlang reducers and Mango selector filters bypass the query server entirely
Q27How do you test CouchDB views, validate_doc_update and sync behaviour in CI?
IntermediateTesting
Answer
Three layers, and interviewers want to hear all three. Unit level: a map function and a VDU are ordinary JavaScript functions, so keep them in real .js or .ts modules and generate the design document JSON at build time by stringifying them. Then test them directly with Vitest or Jest, passing fabricated documents and a fake emit collector, and passing a fabricated userCtx into the VDU.
That catches the bugs that hurt most, a map that throws on a document missing a field and stalls an index build, or a VDU that rejects deletions because it checks for required fields without handling _deleted. Integration level: run a real server, because ICU collation, rereduce behaviour and the Mango query planner cannot be faked. Testcontainers with the official couchdb:3 image, or a disposable Docker container in the CI job, is standard.
Create a uniquely named database per test file, PUT the design documents, seed with _bulk_docs, and DELETE the database in teardown. Do not reuse one database across tests: deletes leave tombstones and the view index stays warm, which hides exactly the index-build failures you are trying to catch. Remember that indexing is lazy, so query each view once after seeding.
Sync level: PouchDB's in-memory adapter simulates offline clients cheaply. Spin up two instances, replicate both against the test server, make divergent edits, sync, and assert that your resolver produces the merged document and that the losing leaves are gone. One assertion worth adding to every suite: POST the production selector to _explain and fail the build if index.type comes back as "special", which is CouchDB telling you the query fell back to a full _all_docs scan.
// orders.view.test.ts (vitest + nano against a throwaway database)
import nano from "nano";
import { beforeAll, afterAll, expect, test } from "vitest";
import { reportingDdoc } from "../ddocs/reporting";
const server = nano("http://admin:secret@127.0.0.1:5984");
const name = `test-orders-${Date.now()}`;
let db;
beforeAll(async () => {
await server.db.create(name);
db = server.use(name);
await db.insert(reportingDdoc);
await db.bulk({ docs: [
{ type: "order", customer: "acme", total: 100, status: "paid", paidAt: "2026-08-01" },
{ type: "order", customer: "acme", total: 250, status: "paid", paidAt: "2026-08-02" },
{ type: "order", customer: "acme", total: 999, status: "draft" },
] });
});
afterAll(() => server.db.destroy(name));
test("revenue view skips drafts and rolls up per customer", async () => {
const res = await db.view("reporting", "revenue", { group_level: 1 });
expect(res.rows).toEqual([{ key: ["acme"], value: 350 }]);
});
test("the production selector hits an index, not a full scan", async () => {
const plan = await server.request({ db: name, path: "_explain", method: "post",
body: { selector: { type: "order", status: "paid" } } });
expect(plan.index.type).not.toBe("special"); // "special" means _all_docs scan
});
Key Points
- Keep map/reduce/VDU code in real modules and generate the ddoc at build time
- Integration tests need a real container; collation and the Mango planner cannot be mocked
- One fresh, uniquely named database per test file, destroyed in teardown
- Assert on _explain so a query cannot silently degrade to a full scan
Q28How would you architect an offline-first field app on PouchDB plus CouchDB, and what actually breaks in the field?
IntermediateOffline-first
Answer
The client keeps a local PouchDB, IndexedDB in a browser or WebView and the SQLite adapter inside a Capacitor or React Native shell. The app reads and writes only that local database, so the UI never waits on a network, and a single live, retrying sync handle streams changes in both directions in the background. Authorisation is the architectural decision, because CouchDB has no per-document read permission: either give each user their own database (couch_peruser or your own provisioning) and fan shared reference data into it with server-side replications, or run one filtered replication per device using a selector on a team or region field. What breaks in real deployments, and what an interviewer with field experience will ask about: the first sync, where a new agent on a 2G link in a district town waits ten minutes and concludes the app is broken, so seed only recent documents with a selector and backfill history lazily; attachments, which cross the wire in full on every sync and drain battery, so keep photos in object storage and sync only the key; conflicts, which are guaranteed rather than hypothetical because a supervisor edits the same record from a laptop, so ship a resolver on the client and a conflict-count view on the server; device clock skew, which makes last-write-wins on a client timestamp unreliable, so prefer a server-stamped field; local growth, handled by auto_compaction:true on the PouchDB instance plus a bounded sync window; and credential expiry, where an expired AuthSession cookie stops live sync with no user-visible error unless you handle the error and denied events and restart the handle after re-login.
import PouchDB from "pouchdb-browser";
const local = new PouchDB("visits", { auto_compaction: true });
const remote = new PouchDB("https://sync.example.in/userdb-6173686121", {
fetch: (url, opts) => { opts.credentials = "include"; return PouchDB.fetch(url, opts); },
});
let handle;
function startSync() {
handle = local
.sync(remote, {
live: true,
retry: true,
batch_size: 50, // small batches survive flaky 2G
back_off_function: d => Math.min((d || 1000) * 2, 60000),
selector: { type: { $in: ["visit", "config"] }, archived: { $ne: true } },
})
.on("change", i => console.log(i.direction, i.change.docs.length))
.on("paused", err => setOnlineBadge(!err)) // err present means offline
.on("denied", err => report("VDU or _security rejected a document", err))
.on("error", err => report("sync stopped, needs restart", err));
}
// An expired AuthSession cookie kills live sync quietly: re-login and restart
async function recover() { handle.cancel(); await login(); startSync(); }
Key Points
- Local-first reads and writes; one live retrying sync handle in the background
- Database-per-user or a selector-filtered replication, because reads are per database
- First sync, attachments and clock skew are the real field failures
- Handle denied and error events; an expired session stops sync silently
Q29What are partitioned databases, and when do they beat a normal database?
AdvancedPartitioning
Answer
Partitioned databases arrived in 3.0 and are the single biggest query-latency lever CouchDB offers. You create the database with ?partitioned=true, and from then on every document _id must be of the form partitionkey:docid. CouchDB hashes only the partition key, so every document sharing a key lands on the same shard.
That unlocks partition-scoped requests: /db/_partition/<key>/_all_docs, /db/_partition/<key>/_find and /db/_partition/<key>/_design/<ddoc>/_view/<view>. A normal query fans out to all q shards and merges the results in the coordinator, so its cost grows with cluster size, whereas a partition-scoped query touches exactly one shard and its cost stays flat as you add nodes. The natural fits are per-device telemetry, per-tenant records and per-user activity, anything where almost every read is already scoped to one entity.
The constraints are real and interviewers check them. The partition key is embedded in the _id, so it is immutable: moving a document between partitions means delete and recreate. A partition should stay well under the [cluster] max_partition_size ceiling, 10 GB by default, so a key that attracts unbounded data (one giant tenant) is a hotspot you cannot fix later.
Indexes are partitioned by default inside a partitioned database and must opt out with options.partitioned = false to serve cross-partition queries, and a global index costs you the fan-out again. The system databases _users and _replicator cannot be partitioned, and you cannot convert an existing database, so this is a design-time decision that requires a full migration to undo.
H=http://admin:secret@127.0.0.1:5984
# Partitioning is chosen at creation and cannot be toggled later
curl -X PUT "$H/telemetry?partitioned=true&q=8"
# The partition key is the part of _id before the first colon
curl -X PUT "$H/telemetry/device-9f2a:2026-08-11T09:15:00Z" \
-H 'Content-Type: application/json' -d '{"type":"reading","battery":72}'
# Partition-scoped query: hits ONE shard, latency flat as the cluster grows
curl -X POST "$H/telemetry/_partition/device-9f2a/_find" \
-H 'Content-Type: application/json' \
-d '{"selector":{"type":"reading","battery":{"$lt":20}},"limit":50}'
curl "$H/telemetry/_partition/device-9f2a/_design/stats/_view/by_hour?group_level=1"
# Watch for hot partitions against [cluster] max_partition_size (10 GB default)
curl -s "$H/telemetry/_partition/device-9f2a" | jq '{doc_count, sizes}'
# A cross-partition index has to opt out explicitly
# { "options": { "partitioned": false }, "views": { "by_firmware": { ... } } }
Key Points
- ?partitioned=true at creation; _id becomes partitionkey:docid and the key is immutable
- Partition-scoped queries hit one shard, so latency does not grow with the cluster
- Indexes default to partitioned; global ones need options.partitioned = false
- Hot partitions and the 10 GB max_partition_size ceiling are unfixable after the fact
Q30How do you do full-text search on CouchDB in 2026: Nouveau, Clouseau or an external engine?
AdvancedSearch
Answer
CouchDB core has no full-text engine, and Mango's $regex is a scan, not an index, so the question is which bolt-on you accept. Historically the answer was Clouseau, a JVM sidecar wrapping an old Lucene, driven by the dreyfus Erlang application, with search indexes declared in a design document's indexes object and queried at /db/_design/<ddoc>/_search/<name>. It works, and it is what Cloudant-style deployments ran for years, but it needs a specific old Java runtime, it is packaged separately, and it has been in maintenance for a long time.
The 3.4 line introduced Nouveau, a rewritten search layer on a modern Lucene running as a small Java service that CouchDB talks to over HTTP. You enable it with [nouveau] enabled = true plus the service URL, declare a nouveau block in the design document whose index function calls index(type, name, value, options) with typed fields (text, string, double, stored), and query at /db/_design/<ddoc>/_nouveau/<name> with q, sort, ranges, counts for faceting and a bookmark for pagination. It has shipped with an experimental label, so the honest interview answer is that you should validate it against your workload and pin your CouchDB version before depending on it.
The third option, and the one most Indian product teams actually run, is to keep CouchDB as the system of record and stream the _changes feed into OpenSearch, Elasticsearch or Typesense, checkpointing the sequence in your own store. That costs you an extra service and eventual consistency, but it gives you analyzers, relevance tuning, aggregations and operational tooling that neither CouchDB option matches. Mango text indexes are worth mentioning only to note that they depend on the same search plugin being installed.
# local.d/nouveau.ini (CouchDB 3.4+ talks to a small Java Lucene service)
[nouveau]
enabled = true
url = http://127.0.0.1:5987
# Design document declaring a Nouveau index
{
"_id": "_design/search",
"nouveau": {
"candidates": {
"default_analyzer": "standard",
"index": "function (doc) {\n if (doc.type !== 'candidate') return;\n index('text', 'skills', (doc.skills || []).join(' '), { store: true });\n index('string', 'city', doc.city || '', { store: true });\n index('double', 'ctcLpa', doc.ctcLpa || 0);\n}"
}
}
}
# Query it
curl "$H/people/_design/search/_nouveau/candidates\
?q=skills:couchdb%20AND%20city:Pune&sort=%22-ctcLpa%3Cdouble%3E%22&limit=25"
# Legacy Clouseau path, 2.x era, needs the JVM plugin:
# /people/_design/search/_search/candidates?q=...
#
# Most production teams instead tail _changes into OpenSearch and
# checkpoint the last seq in their own store.
Key Points
- Clouseau plus dreyfus is the legacy JVM search path exposed at _search
- Nouveau (3.4 line) is the modern Lucene service, queried at _nouveau, still labelled experimental
- Mango $regex is a scan; text indexes need the search plugin installed
- Streaming _changes into OpenSearch is the common production answer
Q31The database-per-user pattern has grown to thousands of databases and the cluster is unstable. Diagnose it.
AdvancedProduction
Answer
This is the classic CouchDB scaling failure, and every symptom traces back to the fact that a database is not free. Each one is q multiplied by n shard files, plus a separate index file per design document per shard, plus its own compaction job and its own replication jobs. Ten thousand user databases at q=2 and n=3 is sixty thousand shard files across the cluster before you count views.
Three ceilings bite in order. First, [couchdb] max_dbs_open, default 500, is an LRU cache of open database handles; once your working set exceeds it CouchDB thrashes, constantly closing and reopening files, and request latency becomes erratic with no obvious CPU or disk saturation. Second, the OS file-descriptor limit on the beam.smp process, which on systemd means LimitNOFILE and not just ulimit in a shell, so raising max_dbs_open without raising LimitNOFILE turns thrashing into emfile errors.
Third, the replication scheduler: [replicator] max_jobs, default 500, means that with five thousand syncing devices the scheduler time-slices jobs in and out on its interval, so sync latency becomes unpredictable and users report that data arrives minutes late. On top of that, smoosh has thousands of compaction candidates and can never catch up, and /_all_dbs becomes unusable, so switch to POST /_dbs_info with an explicit keys array. The fixes, roughly in order of payoff: create user databases with q=1 (they are tiny, sharding them is pure overhead), keep design documents out of user databases, raise LimitNOFILE then max_dbs_open, reap dormant databases on a schedule, and if the tenant count keeps growing, move to one shared database with per-device filtered replication and a validate_doc_update enforcing ownership.
H=http://admin:secret@127.0.0.1:5984
# The three ceilings, in the order they bite
curl -s "$H/_node/_local/_config/couchdb/max_dbs_open" # "500" LRU of open dbs
curl -s "$H/_node/_local/_config/replicator/max_jobs" # "500" scheduler slots
grep 'Max open files' /proc/$(pgrep -f beam.smp | head -1)/limits
# systemd is what actually sets the descriptor ceiling
# /etc/systemd/system/couchdb.service.d/override.conf
# [Service]
# LimitNOFILE=131072
# Real file count on this node (shards + view index files)
find /opt/couchdb/data/shards /opt/couchdb/data/.shards -type f | wc -l
# /_all_dbs is useless at this scale; ask about the databases you care about
curl -X POST "$H/_dbs_info" -H 'Content-Type: application/json' \
-d '{"keys":["userdb-6173686121","userdb-72616a"]}'
# Tiny per-user databases should not be sharded eight ways
curl -X PUT "$H/userdb-6173686121?q=1&n=3"
# Are replications running or waiting for a slot?
curl -s "$H/_scheduler/jobs?limit=1" | jq '{total_rows, offset}'
Key Points
- max_dbs_open (500) is an LRU; exceeding it causes silent open/close thrashing
- LimitNOFILE on beam.smp, not shell ulimit, is the real descriptor ceiling
- [replicator] max_jobs time-slices sync jobs, so latency degrades before anything errors
- Use q=1 for user databases, keep ddocs out of them, and reap dormant ones
Q32How do you choose q, and what can you actually change after a database is created?
AdvancedClustering
Answer
q is the number of shard ranges, fixed when the database is created, and it is the parameter people get wrong in both directions. Too low and each shard file grows past comfortable size, which makes compaction slow and disk-hungry (compaction needs headroom roughly equal to the live data of the shard) and caps how much of the cluster can work on one query in parallel. Too high and every non-partitioned request fans out to q shards on possibly q different nodes and the coordinator merges q sorted streams, so per-query latency and the tail get worse, the file and descriptor count multiplies, and view rebuilds spawn q indexer tasks.
The working rule is to size q so each shard stays in the low single-digit gigabytes at your projected data volume, keep q a small multiple of your node count so shards distribute evenly, and use q=1 for genuinely small databases such as per-user or config databases. Since the 2.4 and 3.x line there is a _reshard API, and knowing it exists separates people who have run a cluster from people who have read a tutorial. POST /_reshard/jobs with {"type":"split","db":"orders"} splits shards live while the database serves traffic, doubling q; you can target a single shard range and node, watch job_state and split_state on GET /_reshard/jobs, and pause the whole subsystem with PUT /_reshard/state when it competes with peak traffic.
What you cannot do is merge: q only goes up. Lowering q, or changing n, means creating a new database with the right layout and replicating into it, then cutting over, which is exactly why the initial choice matters.
H=http://admin:secret@127.0.0.1:5984
# Is resharding available, and what has it done?
curl -s "$H/_reshard" | jq '{state, completed, failed, running}'
# Split every shard range of one database (q doubles, 2 -> 4)
curl -X POST "$H/_reshard/jobs" -H 'Content-Type: application/json' \
-d '{"type":"split","db":"orders"}'
# Or split one specific range on one node
curl -X POST "$H/_reshard/jobs" -H 'Content-Type: application/json' \
-d '{"type":"split","db":"orders",
"range":"00000000-7fffffff",
"node":"couchdb@node1.internal"}'
curl -s "$H/_reshard/jobs" | jq '.jobs[] | {id, job_state, split_state, node}'
# Back off if it is competing with peak traffic
curl -X PUT "$H/_reshard/state" -H 'Content-Type: application/json' \
-d '{"state":"stopped","reason":"IST business hours"}'
# There is no merge. Lowering q means a new database plus a replication.
curl -X PUT "$H/orders-v2?q=4&n=3"
curl -X PUT "$H/_replicator/orders-to-v2" -H 'Content-Type: application/json' \
-d '{"source":"orders","target":"orders-v2","continuous":true}'
Key Points
- Aim for shards in the low single-digit GB; use q=1 for small databases
- Every non-partitioned query fans out to all q shards and merges in the coordinator
- _reshard splits shards online and doubles q; job_state and split_state track progress
- There is no merge, and n changes need a new database plus replication
Q33Design a backup and restore strategy for a CouchDB 3.x cluster.
AdvancedOperations
Answer
Start by saying what replication is not: a continuous replication to a second cluster is a warm standby, not a backup, because an accidental DELETE or a bad bulk update replicates to the standby within seconds. You need both. Layer one is the CouchDB-native copy, a continuous replication into a separate cluster in a different failure domain, which gives you fast failover and validates that your data can actually be read end to end.
Layer two is a point-in-time snapshot you can roll back to. Because the storage format is append-only, a file-level copy of the data directory yields a consistent older state rather than corruption, and the documented technique is to rsync twice so the second pass picks up bytes appended during the first. On AWS the practical version is an EBS or volume snapshot on a schedule, which is atomic and far faster than rsync at scale.
Whatever you copy, copy the whole data directory including the shards and the internal metadata, plus etc/local.d and vm.args, since vm.args carries the Erlang cookie and the node name. That matters because the shard map embeds fully qualified node names such as couchdb@node1.internal, so restoring onto differently named nodes will not simply work; either restore with identical node names or plan a rebuild-and-replicate restore instead. Layer three is the cheap safety net most teams miss: set [couchdb] enable_database_recovery = true, which renames the files of a deleted database with a timestamp suffix instead of removing them, turning an accidental DELETE /db into a recoverable mistake. Finally, rehearse the restore on a schedule and time it, because an untested restore procedure is a guess, and record the RPO your snapshot interval actually implies.
H=http://admin:secret@127.0.0.1:5984
# 0. Undelete safety net: rename shard files instead of removing them
curl -X PUT "$H/_node/_local/_config/couchdb/enable_database_recovery" \
-H 'Content-Type: application/json' -d '"true"'
# 1. Warm standby (NOT a backup: a bad delete replicates instantly)
curl -X PUT "$H/_replicator/orders-dr" -H 'Content-Type: application/json' \
-d '{"source":"http://primary.internal:5984/orders",
"target":"http://dr.internal:5984/orders",
"continuous":true,"create_target":true}'
# 2. Point-in-time copy. Append-only format makes a double rsync safe.
rsync -a --delete /opt/couchdb/data/ /backup/couchdb/data/
rsync -a /opt/couchdb/data/ /backup/couchdb/data/
rsync -a /opt/couchdb/etc/local.d/ /opt/couchdb/etc/vm.args /backup/couchdb/etc/
# 3. The shard map embeds node names, so a restore needs the same names
curl -s "$H/_membership" | jq .cluster_nodes
curl -s "$H/orders/_shards" | jq '.shards | to_entries[0]'
# 4. Prove it: restore into a scratch cluster and diff doc counts
curl -s "$H/orders" | jq '{doc_count, doc_del_count, update_seq}'
Key Points
- Replication is a standby; deletes and bad writes propagate, so keep snapshots too
- Append-only format makes a double rsync or a volume snapshot valid
- Back up vm.args and local.d; the shard map is tied to node names
- enable_database_recovery turns an accidental DELETE /db into a recoverable event
Q34Explain revision trees and _revs_limit, and how pruning causes surprise conflicts with long-offline clients.
AdvancedReplication
Answer
Every document carries a revision tree, not just a current _rev. The tree records the ancestry of each leaf as a list of generation numbers and hashes, and that ancestry is what makes replication safe: when a revision arrives, the target can tell whether it is a descendant of something it already has (fast-forward the branch) or a sibling (create a conflicting leaf). _revs_limit, default 1000 and settable per database with PUT /db/_revs_limit, caps how many ancestor entries are retained. Older entries are pruned, and once pruned they are gone from that database forever.
Now the failure mode. A device syncs, goes offline for a month, and meanwhile the server-side document is updated more times than the retained history allows, so the ancestor the device branched from has been pruned out of the tree. When the device finally pushes, the server cannot prove the incoming revision descends from anything it holds, so it stores it as a new conflicting leaf instead of an update.
The user sees their edit apparently ignored, because the deterministic winner is the server branch with the higher generation. Teams create this themselves by setting _revs_limit low (say 10) to save disk in exactly the offline-first deployment where they can least afford it. The diagnostics worth naming: GET /db/docid?revs_info=true marks each ancestor as available, missing or deleted, _revs_diff returns possible_ancestors when it can only guess, and /_scheduler/jobs exposes missing_revisions_found and revisions_checked. The other side of the coin is documents with runaway revision counts, a counter updated every second, where the tree itself becomes the payload and _bulk_get responses balloon.
H=http://admin:secret@127.0.0.1:5984
# Retained ancestry depth for this database (default 1000)
curl -s "$H/orders/_revs_limit"
curl -X PUT "$H/orders/_revs_limit" -H 'Content-Type: application/json' -d '1000'
# See the tree the way the replicator sees it
curl -s "$H/orders/order:a?revs=true&revs_info=true" \
| jq '{_rev, gen_count: (._revisions.ids | length), _revs_info}'
# _revs_info status is "available", "missing" (pruned away) or "deleted"
# What a pushing client effectively asks before sending bodies
curl -X POST "$H/orders/_revs_diff" -H 'Content-Type: application/json' \
-d '{"order:a":["12-9ab","11-77c"]}'
# -> {"order:a":{"missing":["12-9ab"],"possible_ancestors":["11-77c"]}}
# Counters that expose ancestry trouble after devices reconnect
curl -s "$H/_scheduler/jobs" | jq '.jobs[] |
{id, revisions_checked, missing_revisions_found, doc_write_failures}'
# All leaves of a conflicted document, oldest branch included
curl -s "$H/orders/order:a?open_revs=all" -H 'Accept: application/json'
Key Points
- The revision tree, not just _rev, is what lets replication distinguish update from conflict
- _revs_limit prunes ancestry permanently; the default 1000 exists for a reason
- A long-offline client whose ancestor was pruned lands as a conflicting leaf
- revs_info=true, _revs_diff possible_ancestors and scheduler counters are the diagnostics
Q35When is CouchDB the wrong choice, and what would you propose instead?
AdvancedArchitecture
Answer
Senior interviews include this deliberately, because a candidate who cannot name the limits will not be trusted with the design. CouchDB is the wrong choice when you need multi-document atomicity: there are no transactions, _bulk_docs reports per-document success or conflict, and all_or_nothing was removed, so a double-entry ledger has to be modelled as one document per transaction or moved to Postgres. It is wrong when you need per-document read isolation for many tenants, because _security is per database and views, _all_docs and _changes all see the whole database, which pushes you into database-per-user and the file-descriptor problems that come with it.
It is wrong for ad-hoc analytics: there are no joins, reduce output must shrink, there is no cross-database query, and heavy aggregation belongs in ClickHouse or a warehouse fed off the _changes feed. It is wrong when one document is a write hotspot, since MVCC turns contention into 409 storms. And every secondary index costs you a JavaScript pass through couchjs on every write, so an index-heavy, write-heavy workload is fighting the engine.
What CouchDB uniquely provides is a documented, HTTP-based replication protocol with real client implementations (PouchDB, Couchbase Lite), so an untrusted device can sync directly against the server with validate_doc_update as the enforcement point. If multi-master sync between machines that are frequently disconnected is not in your requirements, you are paying the costs of that design without collecting the benefit, and Postgres with a JSONB column, MongoDB, or DynamoDB will each serve you better. Being able to say that clearly, in an interview for a CouchDB role, reads as judgement rather than disloyalty.
Key Points
- No multi-document transactions and no joins; _bulk_docs is per-document
- No per-document read ACL, so multi-tenant isolation forces database-per-user
- Every JavaScript index costs a couchjs pass on every write
- Its unique value is the replication protocol; without offline sync, pick something else
Frequently Asked Questions
What salary can a CouchDB developer expect in India in 2026?
Roughly ₹6-20 LPA, and the spread is wider than for mainstream databases because CouchDB is almost never the whole job. Backend engineers at services firms such as TCS, Infosys, Wipro, HCLTech, Cognizant, LTIMindtree and Tech Mahindra usually meet it inside a larger stack on healthcare, logistics or field-force projects, landing in the ₹6-14 LPA band at two to six years. The upper end goes to engineers who own the sync architecture end to end: PouchDB or Couchbase Lite on the device, conflict resolution strategy, replication topology, and cluster operations. That skill set is scarce, and it is what product companies and health-tech teams pay ₹18-25 LPA for. IBM, through its long association with Cloudant, remains one of the few employers where CouchDB expertise is named directly in job descriptions.
How long does it take to prepare for a CouchDB interview?
If you already know another document database, two to three focused weeks is realistic. Week one: run couchdb:3 in Docker, finish _cluster_setup, and get fluent with documents, _rev, 409 handling, _all_docs range scans, Mango with _index and _explain, and design documents. Week two: MapReduce, collation, group_level, rereduce, validate_doc_update, and the replication protocol traced with actual curl calls against two containers. Week three: operations, compaction and tombstones, _active_tasks, _scheduler/jobs, clustering with q, n, r and w, and one small offline-first demo with PouchDB. Coming in cold with no NoSQL background, budget five to six weeks. The single highest-return exercise is building a two-node sync demo that deliberately creates a conflict and resolves it, because that story answers half the interview.
Do freshers get hired for CouchDB roles, or is it an experienced-only skill?
There are effectively no fresher openings titled CouchDB, and that is true of most databases. Freshers get hired as backend or full-stack engineers and pick it up because a project uses it, which is exactly how most CouchDB experience in India starts. What that means practically: a fresher should present CouchDB as depth inside a Node.js, Java or Python backend profile, not as the headline. Build something that shows the offline story, a small field-survey app with PouchDB syncing to CouchDB, working aeroplane-mode capture, and a visible conflict resolution step. Experienced hires, at four years and above, are judged on the operational half: view deployment without downtime, compaction, replication monitoring, and cluster sizing decisions they have actually made.
Is CouchDB still worth learning in 2026?
Worth learning, not worth betting a career on alone. It is not growing the way Postgres or ClickHouse are, and the FoundationDB-based 4.0 rewrite was abandoned, so the 3.x line is the product. But the niche it owns is durable: the replication protocol is documented, implemented by PouchDB and Couchbase Lite, and genuinely hard to reproduce, which keeps CouchDB in healthcare, agri-tech, logistics, government field programmes and retail point-of-sale work across India where connectivity is unreliable. The learning also transfers. Once you can reason about MVCC revision trees, deterministic conflict winners and checkpointed sync, distributed systems interviews in general get easier. Treat it as a strong second or third database behind Postgres, and as the specific answer to offline-first requirements.
How does CouchDB compare with MongoDB for a new project?
They look similar (JSON documents, HTTP or wire protocol, no schema) and solve different problems. MongoDB gives you the aggregation pipeline, multi-document ACID transactions, per-field indexing without a query server, change streams and a mature managed service in Atlas, and it is the safer default for a general-purpose document store. CouchDB gives you something MongoDB does not: multi-master replication with conflict-aware clients that run on the device, plus validate_doc_update so an untrusted client can write directly to the server. If your requirement includes phones or edge servers that must work disconnected and reconcile later, CouchDB is a shorter path than building sync on top of MongoDB. If it does not, MongoDB will be less work, and the Indian hiring market for it is many times larger.
How do CouchDB, Cloudant, Couchbase and PouchDB relate to each other?
Worth knowing, because interviewers use the confusion as a filter. Apache CouchDB is the open-source server. IBM Cloudant is a managed service originally built on CouchDB, largely API-compatible, and it is where a lot of enterprise CouchDB work in India actually sits. Couchbase Server is a different product with a different engine and its own query language (N1QL or SQL++), related only by history and name, and confusing the two in an interview is memorable for the wrong reason. Couchbase Lite is Couchbase's mobile database with its own sync via Sync Gateway. PouchDB is the JavaScript database that implements the CouchDB replication protocol in browsers and Node, and it is the standard client half of a CouchDB offline-first stack.
Introduction
Apache CouchDB occupies a very specific niche in 2026, and interviewers know it. It is not a general-purpose document store competing head-on with MongoDB for every workload. It is the database you reach for when the replication protocol itself is the product: field-force apps that work in a village with no signal and sync when the phone finds 4G, hospital and clinic software running on a local server that reconciles with a central instance overnight, retail point-of-sale terminals, and any PouchDB or Couchbase Lite frontend that needs a conflict-aware server on the other end. The 3.x line is the current production family, and the FoundationDB-based 4.0 rewrite that was announced years ago was abandoned, so nothing about the 3.x architecture is a stopgap.
A CouchDB interview therefore probes different muscles than a MongoDB one. Expect questions on MVCC and the _rev token, why a 409 Conflict is normal rather than exceptional, how MapReduce views are built and why editing a design document triggers a full index rebuild, Mango selectors versus views, the exact wire steps of the replication protocol (_revs_diff, _bulk_get, new_edits:false, _local checkpoints), validate_doc_update as a security boundary, and the clustering model with its q shards, n replicas and r/w quorum. Production questions centre on compaction, tombstones, file-descriptor exhaustion under the database-per-user pattern, and the couchjs query-server process pool.
This guide covers 35 CouchDB interview questions asked in 2026, ordered basic first and grouped by topic. Most carry a runnable curl or JavaScript example, because CouchDB is an HTTP API and interviewers frequently ask you to write the actual request. Work through the basic block to lock down documents, revisions, views and Mango, then push into the intermediate and advanced blocks for the material that decides senior offers: conflict resolution strategy, replication topology design, partitioned databases, Nouveau search, and knowing when the honest answer is that CouchDB is the wrong tool.
Ready to practice CouchDB interviews?
Don't just read, practice these CouchDB questions live with an AI interviewer that asks follow-ups and scores your answers.