Article

CouchDB data modeling: what belongs in a document

Decide what a CouchDB document owns, then choose IDs, relationships, indexes, partitions, security boundaries, and deletion rules that fit that decision.

If you have modeled data in Postgres or MongoDB, most of the questions CouchDB asks will sound familiar: what are the entities, how do they relate, and what will you query? The answers change because CouchDB takes away tools you may be leaning on. It has no multi-document transactions, no server-side joins, and no read permissions below the database level. Its replication also lets two copies of the same document change independently.

Those limits stay hidden in a demo. A document that holds ten notes is fine. The same document holding a thousand notes is rewritten in full on every save, and three people are editing it at once.

This article uses one example, orders and support notes in an online store, to show how those limits should shape a model. By the end you should be able to review a CouchDB model, say what each document is responsible for, and predict what will break first. We start with the decision that the rest depend on: what one document owns.

Here is the model we are heading toward. Orders and notes are separate documents in the same customer partition, and two partitioned indexes serve the two reads.

Orders in a partitioned CouchDB database

Open in Studio
Orders and notes share a customer partition, but each has its own revision. Partitioned indexes serve the two read paths.

A document is the unit of change

Almost every CouchDB guarantee is scoped to one document, so that is where modeling starts. A write is atomic for a single document and nothing larger. Each save creates a new revision, and the _rev you send has to match the current one or CouchDB returns a 409. Replication copies whole documents. Size limits, deletes, and validate_doc_update all apply to one document at a time.

The modeling question is therefore not “what is the entity?” It is “which data must change together, and which data needs to change without it?”

An order and its line items pass that test:

{
  "_id": "customer-42:order:019a2f3c",
  "type": "order",
  "schema_version": 2,
  "customer_id": "customer-42",
  "order_id": "019a2f3c",
  "status": "placed",
  "lines": [
    {
      "sku": "SKU-1042",
      "quantity": 2,
      "unit_price_minor": 1299
    }
  ],
  "total_minor": 2598,
  "created_at": "2026-10-19T19:56:00Z"
}

The lines are created with the order, read with the order, and add up to total_minor. Because they share a document, a validate_doc_update function can reject any write where the total does not match the lines, and the rule holds on every replica.

Support notes behave differently. They arrive after the order, come from different people, and have no natural upper bound. If they lived in a notes array on the order, a support agent adding a note and a fulfillment worker marking the order shipped would be editing the same revision. On a single database, one of them gets a 409 and has to retry. With replicas, both saves can succeed and you get a conflict, which the next section covers. Giving each note its own document avoids both:

{
  "_id": "customer-42:note:019a30b1",
  "type": "order_note",
  "customer_id": "customer-42",
  "note_id": "019a30b1",
  "order_id": "019a2f3c",
  "author_id": "user:118",
  "body": "Customer confirmed the delivery address.",
  "created_at": "2026-10-19T20:11:00Z"
}

The cost is an extra query when the order screen needs its notes. I prefer paying it, because the alternative is a document that gets slower and more contested with every note added.

If you know domain-driven design, this is the same call as an aggregate boundary: keep together what one rule needs, and leave out what only appears on the same screen.

Document size is a backstop, not a method. CouchDB defaults to an 8,000,000-byte maximum document body, excluding attachments. A busy document hurts well before that limit, because every update writes, transfers, and replicates a new revision of the whole body.

Expect conflicts when databases replicate

A 409 is the easy case. The client reloads the document and tries again. Replication has no such option. If two replicas accept different edits to the same revision while disconnected, both writes have already succeeded by the time they meet. CouchDB keeps both as branches of the revision tree and picks a winner deterministically, so every replica agrees on which one queries see. The other branch stays in _conflicts until something resolves it.

The winner is a storage decision. The algorithm cannot tell a customer note from a status change, so it may pick the wrong business outcome. Resolving a conflict is application code:

  1. Fetch the document with conflicts=true.
  2. Fetch each revision listed in _conflicts.
  3. Merge or reject the changes according to the document’s business rules.
  4. Save the result against the revision you keep.
  5. Delete the losing leaf revisions.

The merge depends on the data. Tags can combine by set union. Two address edits may need a person to choose. Two status transitions may show that one command was no longer legal. Comparing client timestamps does not settle any of these, especially when the clocks belong to disconnected devices.

Test this before production. Pause replication between two test databases, write to both, reconnect them, and assert the outcome. Test an update against a delete as well. The official conflict guide shows how to fetch and remove conflicting leaves.

This is where the boundary decision pays off. A small document with one clear owner for each field conflicts less often, and its conflicts are simpler to merge.

Choose IDs on the client

A client can lose the HTTP response after CouchDB commits a write. If the client chose the _id, it can retry against the same document. If it asked CouchDB to generate one, the retry can create a duplicate.

Generate IDs before the request. UUIDv7 works when you need decentralized, time-ordered identifiers. An upstream event ID is better when it already identifies the operation you are importing. Either way, the ID should survive changes to names, statuses, and ownership. The CouchDB document design guide recommends client-assigned IDs for the same reason.

Keep the document type and schema version as explicit fields. A type field lets queries tell orders from notes without parsing _id. A schema_version field lets a rolling deployment read old and new shapes on purpose. Write migrations so they are safe to restart. In a large database, migrating a document the next time it is written is often safer than rewriting everything in one batch.

Relate documents without joins

CouchDB has no general-purpose server-side join, which makes duplicating data tempting. Sometimes that is the right choice, as long as the model names one authoritative source for each value.

Say the order page shows a customer name, and the customer later renames themselves. What should an old order show? The order can hold one of three things, and each gives a different answer:

  • A snapshot records the name at the time of the order. The old order keeps the old name.
  • A projection copies the current profile name, and a replayable process keeps the copies fresh. The old order shows the new name, eventually.
  • A reference stores only customer_id, and the reader looks up the name. The old order shows the new name immediately.

For an order I would default to a snapshot. An order is a record of what happened, and an invoice that changes its customer name after the fact is a bug. The design to avoid is the one that never decides, where profile writes and order writes both act as if they own the name.

The same test applies to many-to-many relationships. Suppose users belong to teams, and each membership has its own role and join date. Make the membership a document. It changes independently of both the user and the team, so it needs its own revision.

Design the index from the query

The running example needs the recent orders for one customer. That sentence already contains the query: one customer (the partition), orders (the type), newest first (the sort). You write the query in Mango, CouchDB’s JSON query language, and then build the index that serves it.

List the indexed fields in the order the query uses them. The partition key comes from the endpoint, so it does not need to be an indexed field:

POST /orders/_index
Content-Type: application/json

{
  "index": {
    "fields": ["type", "created_at"]
  },
  "name": "orders-by-date",
  "type": "json",
  "partitioned": true
}

Then query the customer partition instead of the global endpoint:

POST /orders/_partition/customer-42/_find
Content-Type: application/json

{
  "selector": {
    "type": "order",
    "created_at": {
      "$gte": "2026-10-01T00:00:00Z"
    }
  },
  "sort": [
    {"type": "desc"},
    {"created_at": "desc"}
  ],
  "limit": 100,
  "execution_stats": true
}

The notes screen uses a second index on ["type", "order_id", "created_at"] and selects one order’s notes in the same way. That is the extra query the split in the first section costs, and it stays inside one partition.

Run the matching _explain request before you ship a query, then test with the volume and distribution you expect in production. Compare total_docs_examined with results_returned in the execution_stats output. If CouchDB examined 80,000 documents to return 12 orders, check which index it chose before blaming CouchDB’s speed.

JSON indexes work best when the selector describes one contiguous range, built from equality, comparison, or prefix conditions. A condition dominated by $ne has no narrow range to scan. The Mango documentation covers partial indexes and the selectors that need a different shape.

For paging, pass the bookmark that _find returns. skip makes CouchDB walk past rows it already found, so its cost grows with page depth.

Map/reduce views still earn a place for incremental aggregates and ordered reads that Mango does not express well. Do not emit the whole document as the view value unless you want that second copy on disk. The view documentation explains the storage tradeoff.

Partition when reads stay together

A query on an ordinary database has to ask every shard and merge the answers. In a partitioned database, the text before the first colon in _id is the partition key, and a partitioned query goes to the one shard that holds that partition. customer-42:order:019a2f3c and customer-42:note:019a30b1 share a partition, so the order page reads both from one place.

A good key has many distinct values, spreads writes evenly, and groups data you read together. Customer, account, and sensor IDs are typical choices. Status values are a bad one, and so is a single tenant that owns most of the data.

The official guide recommends keeping each partition below 10 GiB with default settings. Estimate your largest customer’s retained data before you pick the key, not after the IDs are in production.

The key is part of _id, so changing it means creating a new document and deleting the old one. Global indexes exist, but a workload dominated by global queries gives up the point of partitioning. For those reads, use an unpartitioned database or build a separate projection.

Treat the database as the security boundary

The _security object gives users and roles membership in a database. A member who can read the database can fetch any document in it by ID. Mango selectors, views, changes-feed filters, and replication filters do not change that.

Database-per-user and database-per-tenant designs can provide that read boundary. They also multiply backup, monitoring, replication, and recovery work, so test those operations at the number of databases you expect to own.

For writes, validate_doc_update can require fields, keep ownership immutable, check roles, and verify that total_minor matches the lines. CouchDB runs the same validation for direct and replicated updates. A validator cannot enforce uniqueness across documents or make several document writes commit together. The database security documentation describes members and roles. The validation documentation shows what a validator receives.

Make bulk writes and feed consumers safe to repeat

_bulk_docs reduces request overhead but does not make the batch a transaction. Check every item in the response, because some documents can succeed while others conflict or fail validation.

Handle each failure for what it is. Retry a transport failure. On a 409, reload the current revision and apply that document’s conflict policy. Fix a validation or authorization error before sending it again. A stable ID prevents duplicate creates, but it does not make a bad update safe.

Consumers of the _changes feed need the same replay safety. Persist the last processed sequence and resume with since. The feed promises the latest change for each document, not every intermediate state. If the intermediate states are the history you need, store them as event documents.

A delete is another write

Deleting a document creates a new revision marked _deleted: true. That revision, called a tombstone, is how other replicas learn to delete their copies. Compaction removes old revision bodies and unused file sections but keeps enough metadata to replicate the deletion.

For high-volume data with one short retention window, rotate databases instead of deleting every document. Move writes to a new database, let consumers and replicas finish with the old one, then delete the expired database.

Purge is a different operation. It removes revision references, and replication does not carry it to other databases, so use it only when you can account for every replica. Compaction also needs temporary disk space while it rewrites database and index files. CouchDB recommends allowing roughly twice the file size.

When the model fights CouchDB

CouchDB fits when a document is a natural unit of change, replicas need to keep working while disconnected, and the important reads can be served by known indexes.

It is a poor fit when the central business rule spans several documents and must commit atomically. It is also a poor fit for open-ended relational queries, or for record-level read permissions inside one shared database. Enough application code can imitate those features, but the amount of code is the signal that the workload wants a different database.

Before you approve a model, pick the document with the highest write rate and walk through one real command:

  • What else can change while this command is in flight?
  • Which fields must commit with it?
  • Which query reads the result, and which index serves that query?
  • Can every database member read every field?
  • What happens after a timeout, a replay, and a delete?

If the answers pull the data in different directions, do not hide the disagreement inside a larger document. Split the write, build a projection for the read, or choose a database whose transaction model matches the work.

If you do one thing after reading this, run that walkthrough on the busiest document in your own database.

References