Document-oriented data modeling

This section documents the current Couchbase Server 7.6.x line as published at the Couchbase Server documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. Some capabilities (Enterprise-Edition-only Analytics, auditing, encryption at rest, the Backup service and rack-zone awareness, and Capella-only App Services and Columnar) are linked, not documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as Couchbase iterates quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

Couchbase stores each record as a JSON document addressed by a key inside a bucket / scope / collection keyspace. There is no CREATE TABLE step: the shape of a document is an application decision, and the central question is how to spread related data across documents — what to embed, what to link by key, and how much to copy.

Embed or reference

The guiding principle is data that is accessed together is stored together. Embedding a related object nests it in the same document, so a single key-value read returns everything and a single write updates it atomically. Referencing keeps the related object in its own document under its own key, joined later with SQL++ or a follow-up KV lookup.

A one-to-many relationship modelled as an embedded array in one document versus separate referenced documents linked by key, with a short decision strip
// embedded: the address is only ever read as part of the user
// collection: main.users.profiles   key: user::u-1
{
  "type": "user",
  "name": "Ada",
  "address": { "street": "1 Loom St", "city": "Turin", "zip": "10100" }
}
// referenced: each order is its own document, linked by userId
// key: user::u-1
{ "type": "user", "name": "Ada" }

// key: order::o-11
{ "type": "order", "userId": "u-1", "total": 42.00 }
// key: order::o-12
{ "type": "order", "userId": "u-1", "total": 19.00 }

Decide with these factors (the document data model):

  • Bounded vs. unbounded arrays. An embedded array that can grow without limit eventually approaches the hard 20 MB per-document ceiling and slows every read of the parent even when the array is not needed. Embed only arrays with a known, small upper bound.

  • Read/write ratio. Embedding favours read-heavy access — one round trip, no join. Referencing favours data written independently of the parent, or updated far more often than it, so a large document is not rewritten on every change.

  • Single-document atomicity. A KV mutation of one document — whole-document or a sub-document path — is atomic without a transaction, so fields that must stay mutually consistent are safer embedded. Spanning documents needs a multi-document transaction.

  • Queried on its own? If the child is frequently filtered, sorted or paged by itself, it wants to be its own document with its own indexes.

Key-based relationships and cardinality

Couchbase has no foreign keys and no server-enforced referential integrity. A relationship is just a document key — or an array of keys — stored on another document, resolved with USE KEYS, an ANSI JOIN on META().id, or a KV get. Deterministic keys such as order::<uuid> or user::<uuid>::cart let the application compute the key of a related document instead of querying for it (document data model).

Cardinality Typical modelling

1:1

Embed (user + preferences), unless the sub-part is large and rarely read — then a separate document under a derived key such as user::u-1::prefs.

1:few (bounded)

Embed as an array — an invoice and its handful of line items.

1:many

Store the parent key on each child document (userId on every order) and query the many side; do not embed an unbounded array.

many:many

An array of keys on one side, or a dedicated link document per edge when the relationship itself carries data (a role, a grade, a joined-at timestamp).

// many:many via a link document, one per edge, in its own collection
// key: enrol::std-1::c-1
{ "type": "enrolment", "studentId": "std-1", "courseId": "c-1",
  "grade": "A", "enrolledAt": "2026-02-01T00:00:00Z" }

High-cardinality relationships — followers, activity feeds, audit trails — are never embedded in full. Model each edge or event as its own document in its own collection, index the parent key, and page through the matches. Keep a followerCount and a small followerSample array on the profile for display, refreshed on write.

SELECT f.follower
FROM main.social.follows AS f
WHERE f.followee = "u-1"
ORDER BY f.createdAt DESC
LIMIT 50 OFFSET 0;

Normalization, denormalization and collections

The relational baseline stores each fact once and rebuilds rows with joins — see SQL Normalization. The document model frequently does the opposite: it copies a few slow-changing fields into the documents that read them so the join disappears at read time. The same trade-off drives MongoDB Schema Design.

The price of duplication is write-time consistency. When a copied value changes, every copy must be updated and readers can briefly see a stale copy. Duplicate only fields that rarely change (a name, a city, a price at time of sale) and keep one clearly identified source of truth.

// denormalized: product name copied onto each order line so rendering an
// order needs no join; a background job refreshes copies on rename.
// https://docs.couchbase.com/server/current/learn/data/document-data-model.html
UPDATE main.sales.orders AS o
SET l.name = "Standing Desk" FOR l IN o.lines WHEN l.sku = "A-1" END
WHERE ANY l IN o.lines SATISFIES l.sku = "A-1" END;

Collections are a first-class modeling tool. A collection groups documents of one entity type, the way a table does, and is the unit of RBAC, indexing and much SQL++ syntax. Prefer one collection per entity type (users, orders, orderLines) inside a meaningful scope over a single type-tagged heap in the bucket’s _default collection (scopes and collections).

Schema versioning. Even with collections, keep a type discriminator and a schemaVersion (or _v) field on every document so the application can read old and new shapes side by side and migrate lazily on write, or in a batched UPDATE.

{ "type": "user", "schemaVersion": 3, "name": "Ada", "emails": ["a@x.io"] }
// batch migration: fold a scalar email into an array, then bump the version
UPDATE main.users.profiles
SET emails = [email], schemaVersion = 2
UNSET email
WHERE schemaVersion < 2;

Anti-patterns and when not to use Couchbase

  • Massive, unbounded arrays. An array that grows per user action (comments, events, followers) will hit the 20 MB document limit and degrade every read. Reference from the many side, or bucket fixed-size windows into separate documents.

  • Oversized documents carrying fields most reads never touch. Split the hot fields from the cold with a derived-key satellite document, or project narrowly in SQL++ and fetch sub-document paths over KV.

  • One giant _default collection. A single heap keyed only by a type field loses per-entity RBAC and forces every index to carry a type predicate. Create scopes and collections per entity.

  • Unindexed case-insensitive lookups. WHERE LOWER(email) = "ada@x.io" without a matching functional index forces a primary scan. Create the index on the same expression (CREATE INDEX).

CREATE INDEX idx_email_ci
  ON main.users.profiles(LOWER(email));

SELECT META().id
FROM main.users.profiles
WHERE LOWER(email) = "ada@x.io";

Couchbase is the wrong fit when the workload is overwhelmingly multi-statement transactional over a rigid, well-known relational shape with strict cross-entity integrity constraints, or leans on ad-hoc joins and aggregation across many equally important normalized entities with no dominant access path. Couchbase supports distributed ACID transactions, but a schema that needs them on most writes is signalling that a relational engine — or a different embed/reference split — would serve better. For the relational baseline see SQL Reference; continue with Querying with SQL++.