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.
// 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 |
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 ( |
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
_defaultcollection. A single heap keyed only by atypefield loses per-entity RBAC and forces every index to carry atypepredicate. 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++.