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.

One-to-many relationship modelled as an embedded array versus separate referenced documents, with a short decision strip
// 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 (authorId on each post), or embed a capped slice with the Subset pattern.

many:many

Arrays of _id on one or both sides, or a dedicated join collection when the relationship itself carries data.

// 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 find with 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 updateMany with 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.