Schema design for the document model
|
This section documents the current MongoDB 8.x server line as published at the MongoDB Server Manual, which is the reference these pages are written and verified against. No specific patch version is pinned. Some capabilities (Atlas Search, Atlas Vector Search, and parts of encryption and backup) are Atlas-only — they are linked, not documented in depth. This content was generated with the assistance of AI and should be verified against the official manual before being relied on in production, since MongoDB iterates quickly. This section’s bibliography lists the reference material consulted while preparing these pages. |
MongoDB has no mandatory collection schema, so schema design is an application concern rather than a CREATE
TABLE step. The central decision is how to distribute related data across documents and collections: what to
embed, what to reference, and how much to duplicate.
Embed or reference
The guiding principle is data that is accessed together is stored together. Embedding a related object puts
it in the same document, so one read returns everything and one write updates it atomically. Referencing keeps
it in a separate document linked by _id.
// embedded: address travels with the user and is only ever read with it
{ "_id": "u-1", "name": "Ada",
"address": { "street": "1 Loom St", "city": "Turin", "zip": "10100" } }
// referenced: each post is its own document, linked by authorId
// authors
{ "_id": 1, "name": "Ada" }
// posts
{ "_id": 11, "authorId": 1, "title": "Engines", "body": "..." }
{ "_id": 12, "authorId": 1, "title": "Notes", "body": "..." }
Decide with these factors (data modeling overview):
-
Bounded vs. unbounded arrays. An embedded array that can grow without limit will eventually approach the hard 16 MB per-document ceiling (BSON document size limit) 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 that is written independently or updated far more often than the parent, so a large document is not rewritten on every change.
-
Single-document atomicity. A write to one document is atomic without a transaction, so fields that must stay mutually consistent are safer embedded. Spanning documents needs a multi-document transaction — see Transactions.
-
Queried on its own? If the child is frequently queried or sorted by itself, it wants to be its own document (and its own indexes) — see Indexes.
Relationship cardinality
| Cardinality | Typical modelling |
|---|---|
1:1 |
Embed (user + profile), unless the sub-part is large and rarely read. |
1:few (bounded) |
Embed as an array — order + line items, article + a handful of tags. |
1:many |
Reference from the many side ( |
many:many |
Arrays of |
// many:many by id arrays
{ "_id": "std-1", "name": "Ada", "courseIds": ["c-1", "c-2"] }
// many:many via a join collection that also stores relationship data
{ "_id": 1, "studentId": "std-1", "courseId": "c-1", "grade": "A", "enrolledAt": { "$date": "2026-02-01T00:00:00Z" } }
High-cardinality relationships such as friends / followers are never embedded in full: store each edge as
its own document, index both directions, and page through them. Keep a small followerSample array and a
followerCount on the profile (the Computed pattern below) for display.
// followers collection: one document per edge
{ "_id": 1, "follower": "u-9", "followee": "u-1" }
{ "_id": 2, "follower": "u-4", "followee": "u-1" }
See the data modeling overview for the cardinality guidance in full.
Normalization vs. denormalization
The relational baseline stores each fact once and reconstructs rows with joins — see Normalization. The document model often does the opposite: it copies a few slow-changing fields into the documents that read them so the join disappears.
The price is write-time consistency. When a duplicated value changes, every copy must be updated, and readers can briefly see stale copies. Duplicate only fields that rarely change (a name, a city), and keep one clearly identified source of truth.
// denormalized: product name copied onto each order line
// { _id: "o-1", lines: [ { sku: "A-1", name: "Desk", qty: 2 } ] }
// when a product is renamed, a background job refreshes the copies
// https://www.mongodb.com/docs/manual/data-modeling/
db.orders.updateMany(
{ "lines.sku": "A-1" },
{ $set: { "lines.$[l].name": "Standing Desk" } },
{ arrayFilters: [ { "l.sku": "A-1" } ] }
)
Schema design patterns
The following patterns recur across document schemas (design patterns catalog).
Bucket — collapse many small rows (time-series readings, log lines) into one document per time window, carrying pre-aggregated fields.
{ "_id": { "sensor": 7, "hour": { "$date": "2026-08-30T14:00:00Z" } },
"count": 60, "sum_v": 1284.0,
"readings": [ { "t": 0, "v": 21.4 }, { "t": 1, "v": 21.5 } ] }
Computed — store the result of an aggregation next to the source data, refreshed on write or on a schedule, so reads never recompute it.
{ "_id": "movie-1", "title": "Ronin",
"ratingCount": 1204, "ratingSum": 5418, "ratingAvg": 4.5 }
Subset — keep only the hot slice of a large related set in the main document; the rest lives in another collection.
{ "_id": "prod-9", "name": "Desk",
"recentReviews": [ /* last 5 */ ], "reviewCount": 812 }
Extended Reference — copy the handful of fields you always display from a referenced document, not just its
_id, to avoid a $lookup on the hot path.
{ "_id": "order-5",
"customer": { "_id": "c-1", "name": "Ada", "city": "Turin" },
"lines": [ /* ... */ ] }
Schema Versioning — tag every document with a version so the application can handle old and new shapes side by side.
{ "_id": 1, "schemaVersion": 3, "name": "Ada", "emails": ["a@x.io"] }
Outlier — handle the rare oversized document with a flag plus an overflow collection, so the common case stays small.
{ "_id": "u-1", "name": "Ada", "followerSample": [ /* ... */ ], "hasMany": true }
Polymorphic — store related-but-differently-shaped entities in one collection with a discriminator field.
{ "_id": 1, "kind": "car", "wheels": 4, "doors": 5 }
{ "_id": 2, "kind": "boat", "hullLength": 9.2 }
Tree / Graph — model a hierarchy with a parent reference, an ancestors array, and/or a materialized
path; traverse it with $graphLookup (see The aggregation
framework).
{ "_id": "c", "parent": "b", "ancestors": ["a", "b"], "path": ",a,b," }
Anti-patterns
Common mistakes, with fixes (schema anti-patterns):
-
Massive, unbounded arrays — switch to the Bucket or Subset pattern, or reference from the many side.
-
Bloated documents carrying fields most queries never read — project less, or split with the Subset pattern.
-
Too many collections, many of them tiny — each collection has a namespace and per-index overhead; a polymorphic collection is often better.
-
Unindexed case-insensitive queries — a
findwith a collation that no index shares forces a collection scan. Create the index with the same collation (case-insensitive indexes).
db.users.createIndex({ email: 1 }, { collation: { locale: "en", strength: 2 } })
db.users.find({ email: "ADA@x.io" }).collation({ locale: "en", strength: 2 })
See Indexes for the indexing side of these fixes.
Schema versioning and migration
Add a schemaVersion (or _v) field and roll changes out in one of two styles
(Schema Versioning pattern):
-
Migrate-on-read — the application upgrades a document’s shape when it loads it and writes the new shape back lazily. No downtime; old documents linger until touched.
-
Batch migration — a background job rewrites every document up front, typically an
updateManywith an aggregation pipeline. Predictable end state; heavier one-off cost.
// https://www.mongodb.com/docs/manual/data-modeling/
db.users.updateMany(
{ schemaVersion: { $lt: 2 } },
[ { $set: { schemaVersion: 2, emails: ["$email"] } },
{ $unset: "email" } ]
)
Running this kind of batch migration as a version-controlled, tracked unit of work — rather than an ad-hoc script — is exactly what Mongock and its successor Flamingock are for; see Evolving the Database Model for the full guide.
When not to use MongoDB
The document model is a poor fit when the workload is overwhelmingly multi-document transactional over a rigid, well-known relational shape, or leans on ad-hoc joins and aggregation across many equally important entities. MongoDB supports multi-document transactions (Transactions), but they are designed to be the exception; a schema that needs them routinely is signalling that a relational engine, or a different embed/reference split, would serve better. Small, highly-normalized reference data governed by strict cross-entity integrity constraints is another case where the relational model earns its keep — see Normalization.