Filter concepts by levelShowing all levels.

MongoDB · Section 6

Data Modeling

Level
advanced
Read
35 min
Concepts
9

MongoDB's core senior skill: treating schema design as workload-driven, starting from access patterns and workload types rather than entities alone, deciding what to embed versus reference and why, bounded vs. unbounded arrays, the real risk of unbounded document growth, and the co-location principle underlying all of it.

This section

What is true here

  1. Schema design starts from the application's access patterns and workload types, not from a normalized entity list.
  2. MongoDB's core principle: data accessed together should be stored together — the reasoning behind "embed by default."
  3. Embedding buys locality, atomic single-document updates, and simple reads, paid for as document growth.
  4. Referencing buys independent growth, reuse, and small documents, paid for as an extra query or $lookup.
  5. A bounded array has an enforced cap; an unbounded one risks the hard 16 MiB document limit as it grows.

What you will be able to do

  • List an application's real access patterns and workload types before shaping a schema
  • Decide whether to embed or reference a given relationship, and justify the choice by its actual tradeoffs
  • Recognize whether an embedded array is genuinely bounded, or only small by coincidence
  • Predict the gradual-then-hard failure mode of an unbounded embedded array before it happens in production
  • Apply the co-location principle by access pattern, not only to conceptually "related" entities

The workload-driven mindset

Why MongoDB schema design starts from access patterns and workload types, and the co-location principle underneath it all.

Schema design as workload-driven design

coreintermediate

A MongoDB schema is designed around how the app reads and writes data, not around normalizing entities. The same real-world data can be modeled two very different ways depending on the workload.

Think of it as

A relational schema asks "what are the entities and their relationships," then normalizes to avoid duplication, and the query layer is expected to adapt via joins. MongoDB flips the order: ask "what will the application actually query, and how often," then shape documents so the common queries are cheap — even if that means the same data appears in more than one place. The schema is downstream of the workload, not the other way around.

text
// Not a syntax — a design order: access patterns first, document shape second.

What we're doing: Show the same two entities (a blog post and its comments) modeled two different ways for two different workloads.

workload-shapes-the-schema.txttext
// Workload A: readers view a post with all its comments together, comments rarely edited alone
{ _id: 1, title: "...", comments: [ { user: "a", text: "..." }, { user: "b", text: "..." } ] }

// Workload B: comments are moderated independently, searched across posts, and can be very numerous
{ _id: 501, postId: 1, user: "a", text: "..." }
{ _id: 502, postId: 1, user: "b", text: "..." }
2
Workload A embeds comments because they are always read with the post — one query gets everything.
5
Workload B references comments in their own collection because they are queried and moderated independently of any one post.

Why this works: Neither shape is "more correct" in the abstract — a relational-thinking reviewer might flag the embedded version as denormalized, but it is the right choice if workload A is real. The schema is a means to a performance end, not a modeling exercise judged on its own terms.

Modeling entities and relationships first, then asking how to query them

Wrong

text
// Designed collections to mirror the ER diagram, then discovered every page load needs 4 queries across them

Better

text
// Listed the app's actual queries first ("show a post with its comments", "find a user's posts"), then shaped documents to answer the common ones in one query

What you see: The schema looks clean and normalized, but common pages require several sequential queries or application-side joins to assemble one response.

Why: A schema built to mirror entities, without first asking what the application actually queries, tends to need $lookup or client-side joins for every common read — the exact cost MongoDB's document model exists to avoid.

Workload first, schema second

access patterns

what gets queried, how often

schema shape

documents, embedding, indexes

performance

the point of the exercise

  1. access patterns — what gets queried, how often
  2. schema shape — documents, embedding, indexes
  3. performance — the point of the exercise

Remember: Design the schema around the application's actual read/write patterns first — not around normalized entities the way a relational schema would be.

See also: access patterns not entities · workload types

Start from access patterns, not only entities

coreintermediate

Before designing collections, list the specific queries the app will actually run — which fields, how often, with what else — and let those drive the shape. Entities alone don't say enough to design around.

Think of it as

An entity list answers "what nouns exist in this domain." An access-pattern list answers "what will the application ask for, and how often." Two apps with identical entities (users, posts, comments) can need opposite schemas if one reads posts-with-comments constantly and the other searches comments independently across posts — the entity list alone cannot tell you which.

text
// Design input: a list of { query, frequency, latency requirement } — not just an entity-relationship diagram.

What we're doing: Show two access patterns on the same two entities leading to a schema an entity-only design would not have picked.

access-pattern-list.txttext
// Access patterns:
// - Every page load: "get this order with its line items" (very frequent)
// - Monthly job: "sum revenue per product across all orders" (rare, can be slow)

db.orders.insertOne({ _id: 1, customer: "a", items: [ { sku: "X1", qty: 2, price: 9.99 } ] })
// items embedded — pattern 1 needs them together, pattern 2 can afford a slower aggregation scan
1
Listing patterns with frequency first makes the tradeoff explicit before any document shape is chosen.
5
Items are embedded because the frequent pattern needs them with the order; the rare pattern can absorb the cost of scanning embedded arrays.

Why this works: If the monthly revenue job were instead run every few seconds for a live dashboard, the same entities would likely justify a separate, indexed line-items collection instead — the entities did not change, the access pattern did.

Designing collections straight from an ER diagram without ever listing real queries

Wrong

text
// "Users have Orders have LineItems" -> three collections, one per entity, joined at query time

Better

text
// First: "what does the app actually query, and how often?" -> then decide embed vs reference per relationship

What you see: The schema mirrors the domain model cleanly, but the application's most common query needs a $lookup or several round trips to assemble.

Why: An ER diagram describes relationships, not frequency or co-access — a schema built only from it optimizes for looking correct on a whiteboard, not for the queries the application will actually run.

Design input, in order

List entities

User, Post, Comment — necessary, not sufficient

List access patterns

"profile view: user + 20 recent posts", frequency noted

Weigh by frequency

a page-load query outweighs a monthly report

Shape the schema

embed or reference follows from the patterns above

  1. List entities — User, Post, Comment — necessary, not sufficient
  2. List access patterns — "profile view: user + 20 recent posts", frequency noted
  3. Weigh by frequency — a page-load query outweighs a monthly report
  4. Shape the schema — embed or reference follows from the patterns above

Entity list vs. access-pattern list, same domain

Entity list vs. access-pattern list, same domain
Question typeExampleWhat it tells you
Entity (insufficient alone)"There is a User and a Post"nothing about how they are queried together
Access pattern"Show a user's 20 most recent posts, on every profile view"frequency, sort, and shape needed — argues for embedding or a targeted index
Access pattern"Search all posts by tag, across all users, rarely"a separate, independently queryable posts collection

Together

text
// Access pattern list drives the schema, not the entity list alone:
// 1. "Render a user's profile with their 20 most recent posts" — frequent, needs speed
// 2. "Admin: find all posts by tag across users" — rare, can tolerate a slower query
// -> posts likely live in their own collection (pattern 2), with a compound index
//    on (userId, createdAt) serving pattern 1 efficiently without embedding

Remember: List the actual queries — fields, frequency, together-with-what — before shaping collections. Entities alone under-specify the design.

See also: workload driven design · workload types

Workload types to identify

standardintermediate

Six workload shapes come up repeatedly: read-heavy, write-heavy, high-cardinality, time-series, hierarchical, transactional. Naming one helps pick the right pattern instead of reasoning from scratch.

Think of it as

Each workload type is a recognizable shape with known-good patterns, the same way a "producer-consumer" or "cache" label points a backend engineer toward known solutions. Naming the workload turns "how should I model this" into "which of the six familiar shapes is this closest to," which is a much smaller question.

text
// Not a syntax — a classification exercise applied per access pattern, before choosing embed/reference/index shape.

What we're doing: Show a single feature area (e-commerce orders) exhibiting three different workload types across three of its access patterns.

mixed-workload-types.txttext
// Same "orders" data area, three different workload types:

// 1. Placing an order: write-heavy + transactional (deduct inventory, create order, atomically)
db.orders.insertOne({ ... })  // inside a multi-document transaction with an inventory update

// 2. Customer's order history: read-heavy
db.orders.find({ customerId }).sort({ createdAt: -1 })  // frequent, wants speed over write cost

// 3. Daily revenue report: time-series-shaped, read pattern is aggregation-over-time
db.orders.aggregate([ { $match: { createdAt: { $gte: startOfDay } } }, { $group: { _id: null, total: { $sum: "$amount" } } } ])
2
Placing an order needs atomicity across two pieces of data — a transactional workload.
4
Reading order history is frequent and read-dominant — a read-heavy workload, worth denormalizing for.
6
The revenue report is naturally time-ordered and aggregated — a time-series-shaped access pattern even though "orders" is not usually called a time-series collection.

Why this works: The same collection can serve access patterns of different workload types — recognizing that per-pattern, rather than trying to label the whole collection one type, is what keeps the schema decision grounded in the actual queries.

Applying one workload label to an entire collection instead of per access pattern

Wrong

text
// "Orders are read-heavy, so I'll denormalize everything" — including the write path that needs strict consistency

Better

text
// Recognize the write path (placing an order) is transactional, while the read path (history) is read-heavy — design each accordingly

What you see: A schema optimized for one access pattern (e.g. reads) makes a different, equally important access pattern (e.g. the transactional write) awkward or unsafe.

Why: A single collection commonly serves more than one access pattern, and those patterns can be different workload types — the classification is a tool applied per pattern, not a single verdict on the whole collection.

Name the workload, then reach for its pattern

six workload types

recognizable shapes

known pattern

per type

schema decision

not from scratch

  1. six workload types — recognizable shapes
  2. known pattern — per type
  3. schema decision — not from scratch

The six workload types

The six workload types
TypeSignalCommon modeling response
Read-heavyreads vastly outnumber writesembed/denormalize for one-query reads
Write-heavywrites vastly outnumber readskeep documents small; minimize indexes updated per write
High-cardinalityfield has many distinct valuesindex carefully; consider it for shard keys
Time-seriesdata ordered/queried by timebucket by interval; consider time-series collections
Hierarchicalparent/child, tree-shaped datamaterialized paths, tree references, or nested sets
Transactionalneeds multi-document atomicitymulti-document transactions, or model to avoid needing them

Together

text
// A single feature often mixes types:
// - "Order placement" is write-heavy AND transactional (inventory + order must agree)
// - "Order history page" is read-heavy on the same underlying data
// The two access patterns on the same entity can justify different schema choices for each.

Remember: Six workload types — read/write-heavy, high-cardinality, time-series, hierarchical, transactional — apply per pattern, not per collection.

See also: access patterns not entities · embed or reference decision

Advertisement

Embed vs. reference

The central decision, its tradeoffs in both directions, and the array-growth risk that makes bounded-ness matter.

Deciding what to embed and what to reference

coreintermediate

MongoDB's official guidance: embed by default, since it answers a query in one read. Reference instead when the data grows independently, is shared across many parents, or is updated far more often than it is read together with its parent.

Think of it as

Embedding says "this related data lives inside its parent because it is always read with it." Referencing says "this related data has its own lifecycle and identity, and belongs in its own collection." The decision is not about how the data is related conceptually — it is about whether the two pieces are read together often enough, and updated independently enough, to justify keeping them apart.

text
// Embed: { parent: { ..., child: { ... } } }
// Reference: { parent: { ..., childId: <id> } }  and a separate child collection

What we're doing: Show the same "movie has a user" relationship modeled two ways depending on which access pattern dominates.

embed-vs-reference-same-relationship.txttext
// If the app shows a lightweight review snapshot beside a movie (name, not full profile):
db.movies.insertOne({ title: "...", review: { name: "Joel M", rating: 5 } })

// If the app also needs the same user's full, frequently-updated profile elsewhere,
// and that profile is shared across many movies the user reviewed:
db.movies.insertOne({ title: "...", userId: 987 })
db.users.insertOne({ _id: 987, name: "Joel M", email: "...", bio: "..." })
2
A small, rarely-changing snapshot embedded directly avoids a $lookup for the common "show the review" read.
6
The full user profile is referenced because it is shared across every movie the user reviewed, and updating a bio should not mean rewriting every movie document that mentions them.

Why this works: Both are "the same relationship" (movie-to-user) modeled differently because the actual questions — how often read together, how often updated independently, how widely shared — have different answers for a review snapshot versus a full profile.

Embedding a frequently-updated, widely-shared entity because it "belongs" conceptually

Wrong

text
// Full user profile embedded in every movie they reviewed
db.movies.insertOne({ title: "...", user: { name: "Joel M", email: "...", bio: "...", followerCount: 1204 } })

Better

text
db.movies.insertOne({ title: "...", userId: 987 })  // reference the shared, independently-updated entity

What you see: Updating one user's bio or follower count requires finding and rewriting every movie document that embedded a copy of their profile.

Why: Embedding duplicates the data into every parent that references it — fine for data that rarely changes, expensive and error-prone for data updated independently and shared widely, which is exactly what a reference is for.

Embed by default, reference on these signals

Embed (default)

read together often

one query, no $lookup

bounded, parent-owned

naturally belongs inside

Reference

shared across parents

avoid duplicated updates

independent lifecycle

updated separately, grows unbounded

  • Embed (default)
    • read together often — one query, no $lookup
    • bounded, parent-owned — naturally belongs inside
  • Reference
    • shared across parents — avoid duplicated updates
    • independent lifecycle — updated separately, grows unbounded

Three questions that decide embed vs. reference

Three questions that decide embed vs. reference
QuestionPoints toward
Is this data almost always read together with its parent?Embed
Is this data updated independently of its parent, often?Reference
Is this data shared across many parent documents?Reference
Could this data grow without a natural bound?Reference

Together

text
// Movie + a user's profile info shown alongside a review: embed a small snapshot
{ title: "...", review: { user: "Joel M", rating: 5, text: "..." } }

// Movie + the full user account (many movies reference the same user): reference
{ title: "...", userId: 987 }
{ _id: 987, name: "Joel M", email: "..." }

Remember: Embed by default — one read, no $lookup. Reference when data is updated independently, shared across many parents, or can grow unbounded.

See also: embedding tradeoffs · referencing tradeoffs

Embedding trade-offs

coreintermediate

Embedding gives locality (one read gets everything), atomic single-document updates, and simpler application code — at the cost of documents that can grow, sometimes without a natural bound.

Think of it as

Embedding trades "spread the data out, join it back together at query time" for "keep it together, pay for that up front." The three benefits (locality, atomicity, simplicity) all come from the same fact — the related data lives inside one document — and so does the one cost: that document's size grows with however much gets embedded into it.

text
{ parent: { ..., embeddedArray: [ {...}, {...} ] } }  // grows as items.push(), one document, one read

What we're doing: Show the atomic-update benefit concretely, and where the same embedding starts to cost more as the array grows.

atomic-update-vs-growth.txttext
// Benefit: this update is atomic — another reader never sees a half-updated items array
db.orders.updateOne({ _id: 1, "items.sku": "X1" }, { $inc: { "items.$.qty": 1 } })

// Cost: as items grows from 3 to 3,000 (e.g. a "cart" that never gets cleared),
// every read and every write of this document moves the whole array, even to change one qty field
db.orders.updateOne({ _id: 1, "items.sku": "X1" }, { $inc: { "items.$.qty": 1 } })
// -> same operation, now rewriting a much larger document each time
1
A single-document update to a nested array element is atomic by default — this is the real benefit, not a marketing simplification.
6
The identical operation on a document that grew unbounded now costs more, because MongoDB still has to read and rewrite the whole containing document.

Why this works: The tradeoff is not "embedding is bad past some size" — it is that the same embedding decision has a cost curve that rises with how much gets embedded, so the "bounded" qualifier on the earlier embed-or-reference decision is what keeps this benefit worth its cost.

Embedding an array with no natural bound and expecting the atomic-update benefit to stay free

Wrong

text
// A "user activity log" embedded and appended to forever
db.users.updateOne({ _id: u }, { $push: { activityLog: newEvent } })

Better

text
// Reference activity events in their own, time-indexed collection instead
db.activityEvents.insertOne({ userId: u, event: newEvent, at: new Date() })

What you see: Write latency to the user document slowly increases over the account's lifetime, and the document eventually risks the 16 MiB document size limit.

Why: The atomic-update and locality benefits of embedding do not disappear, but their cost is proportional to document size — an unbounded array turns a cheap, fast update into a progressively more expensive one, and the array itself becomes the growth problem covered next.

Embedding: what it buys vs. what it costs

Benefits

  • +Locality — one read gets everything
  • +Atomic single-document updates
  • +Simpler reads — no client-side assembly

Cost

  • Every embedded item grows the parent
  • 16 MiB document size ceiling
  • Larger documents cost more to rewrite
  • Benefits
    • Locality — one read gets everything
    • Atomic single-document updates
    • Simpler reads — no client-side assembly
  • Cost
    • Every embedded item grows the parent
    • 16 MiB document size ceiling
    • Larger documents cost more to rewrite

Embedding: benefit vs. its matching cost

Embedding: benefit vs. its matching cost
BenefitWhat it buysMatching cost
Localityone read returns everythingthe document is exactly as large as everything embedded
Atomic updatesa nested field/array update is atomicno cross-document atomicity is needed, but growth still applies
Simpler readsno application-side join logicschema flexibility can let embedded shapes drift over time
(all three)fast, simple accessa 16 MiB document size ceiling, and growing documents cost more to rewrite

Together

text
db.orders.updateOne(
  { _id: 1, "items.sku": "X1" },
  { $set: { "items.$.qty": 3 } }
)
// -> the update to one embedded array element is atomic — no partial-write state is ever visible

Remember: Embedding buys locality, atomic single-document updates, and simple reads — paid for as document growth, up to the 16 MiB limit.

See also: embed or reference decision · referencing tradeoffs · bounded vs unbounded arrays

Referencing trade-offs

coreintermediate

Referencing gives independent growth, reuse of shared data across many documents, and smaller documents per record — at the cost of needing an extra query or a $lookup to assemble related data back together.

Think of it as

Referencing is embedding's mirror image: instead of paying an upfront document-size cost for locality, referencing pays a per-read cost (an extra query or a $lookup) in exchange for keeping each document small and each shared entity stored exactly once. It is the right trade when the "extra query" cost is rare or cheap relative to how often the independence and reuse actually matter.

text
{ parent: { ..., childId: <id> } }   // small, references a separate collection
db.<collection>.aggregate([ { $lookup: { from, localField, foreignField, as } } ])  // join at query time

What we're doing: Show the extra-query cost concretely, and where reuse pays that cost back.

reuse-vs-extra-query.txttext
// A user referenced by 10,000 movie reviews: update once, every reference reflects it
db.users.updateOne({ _id: 987 }, { $set: { bio: "New bio" } })
// -> no need to touch any of the 10,000 movie documents

// Cost: showing a movie WITH the user's name still needs a second query or $lookup
db.movies.aggregate([
  { $match: { _id: 1 } },
  { $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } },
])
1
This is the reuse benefit paying off directly — one update, no fan-out to every document that references this user.
6
This is the matching cost — reading a movie with its user's name now needs a $lookup stage that an embedded snapshot would not have needed.

Why this works: The trade only makes sense when reuse/independent-growth genuinely matters — if this user were referenced by exactly one movie and never updated independently, embedding would give the same correctness with none of the $lookup cost.

Referencing data that is never actually shared or updated independently

Wrong

text
// A one-to-one "shipping address" referenced in its own collection, even though it belongs to exactly one order and never changes after the order ships

Better

text
// Embed it — there is no reuse or independent-growth benefit to justify the extra query
db.orders.insertOne({ ..., shippingAddress: { street: "...", city: "..." } })

What you see: Every order read needs an extra query or $lookup for data that was never shared, never updated separately, and never grew independently.

Why: Referencing's benefits (independent growth, reuse, smaller documents) only pay for the $lookup cost when they are real — data with a one-to-one, bounded, never-independently-updated relationship gets none of referencing's upside and all of its query cost.

Referencing: what it buys vs. what it costs

Benefits

  • +Independent growth — not bounded by a parent
  • +Reuse — update once, every reference reflects it
  • +Smaller documents — just an id

Cost

  • An extra query, or a $lookup, per read
  • No automatic denormalized copy for fast reads
  • More round trips to assemble a full view
  • Benefits
    • Independent growth — not bounded by a parent
    • Reuse — update once, every reference reflects it
    • Smaller documents — just an id
  • Cost
    • An extra query, or a $lookup, per read
    • No automatic denormalized copy for fast reads
    • More round trips to assemble a full view

Referencing: benefit vs. its matching cost

Referencing: benefit vs. its matching cost
BenefitWhat it buysMatching cost
Independent growtha shared entity is never bounded by a parent's sizean extra query to fetch it
Reuseupdate once, every reference reflects itno automatic denormalized copy for fast reads
Smaller documentsthe parent stays lean regardless of how much related data existsassembling a full view needs more round trips
(all three)flexibility and single-source-of-truth updatesa $lookup or app-level join where embedding needed none

Together

text
db.movies.findOne({ _id: 1 })                       // returns { title, userId: 987 }, small
db.users.findOne({ _id: 987 })                        // a second query for the related data
// or, in one round trip:
db.movies.aggregate([ { $match: { _id: 1 } }, { $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } } ])

Remember: Referencing buys independent growth, reuse, and small documents — paid for with an extra query or $lookup per read.

See also: embed or reference decision · embedding tradeoffs

Bounded vs. unbounded arrays

standardintermediate

A bounded array has a natural, small, predictable maximum size (a product's 3-5 variants). An unbounded array has no such ceiling (every comment ever posted, every event ever logged) and keeps growing for the life of the document.

Think of it as

The question is not "how big is this array today" but "does anything stop it from growing." A list of a person's five most recent addresses is bounded by the "five" rule itself; a list of every order they have ever placed is not bounded by anything in the domain — it grows for as long as the account exists. Boundedness is a property of the relationship, not a snapshot of current size.

text
$push: { arrayField: { $each: [<item>], $slice: <-N> } }  // keeps an embedded array bounded to the last N items

What we're doing: Show $slice actively enforcing a bound on an otherwise-growing array, versus a comments array with nothing enforcing one.

enforced-bound-vs-none.txttext
// Bounded by $slice: this array can never exceed 5 elements, no matter how many logins happen
db.users.updateOne({ _id: u }, { $push: { recentLogins: { $each: [newEvent], $slice: -5 } } })

// Unbounded: nothing in this operation limits how large "comments" can become
db.posts.updateOne({ _id: p }, { $push: { comments: newComment } })
// -> a popular post can accumulate thousands of comments, each $push moving a larger and larger document
2
$slice: -5 actively enforces the bound — the array is capped by the operation itself, not just by assumption.
5
Nothing here prevents unbounded growth; "popular post" is exactly the case where this array has no natural ceiling.

Why this works: The $slice example shows that "bounded" is not just about small data — it is about whether something in the schema or the update operation actually enforces a limit, versus the array being unbounded in practice even if it happens to be small today.

Embedding an array because it is small right now, without checking whether anything bounds it

Wrong

text
// "comments only has 3 items in my test data" -> embedded directly, no bound enforced

Better

text
// Ask: what stops this from growing? If nothing does, reference it or enforce a bound (e.g. $slice, or a subset pattern)

What you see: A schema that performed fine in testing or early production degrades as real usage accumulates more items in an array that was never actually bounded.

Why: Current size is not evidence of boundedness — only a rule that actively caps growth (a domain constraint, or an operation like $slice) does. "Small today" and "bounded" are different claims, and only the second one is safe to embed on.

Bounded vs. unbounded, by example

Bounded vs. unbounded, by example
ExampleBounded?Why
A product's size variantsboundeda product realistically has a handful of sizes
A user's last 5 login eventsboundedcapped by the "last 5" rule itself, e.g. via $slice
All comments on a popular postunboundednothing stops comment count from growing indefinitely
A user's full order historyunboundedgrows for the account's entire lifetime

Together

text
// Bounded, safe to embed with an enforced cap:
db.users.updateOne({ _id: u }, { $push: { recentLogins: { $each: [event], $slice: -5 } } })

// Unbounded, better referenced in its own collection:
db.comments.insertOne({ postId: p, user: u, text: "..." })

Remember: Bounded means something actively caps growth (a domain rule, or $slice) — not just "it's small today." Unbounded arrays are a signal to reference.

See also: embedding tradeoffs · document growth risk

Document growth and the risks of unbounded embedding

standardadvanced

Documents have a hard 16 mebibyte size limit. An unbounded embedded array works fine while small, then degrades gradually — larger reads, larger rewrites — before eventually hitting the limit and failing writes.

Think of it as

Document growth is a two-stage risk, not one. Stage one is gradual: every read and every write of a growing document moves more bytes than it needs to, so latency creeps up long before anything breaks. Stage two is a hard wall: at 16 MiB, an insert or update that would grow the document further simply fails. Most teams feel stage one's slow degradation well before stage two's hard failure — which makes it easy to mistake for "normal" and not trace back to the real cause.

text
// No syntax to show — the risk is structural: an unbounded $push target inside one document.

What we're doing: Show the exact failure mode a design allowed to happen, and the reference-based fix that avoids it entirely.

growth-failure-and-fix.txttext
// The design that allows unbounded growth:
db.posts.updateOne({ _id: p }, { $push: { comments: newComment } })
// -> a popular post's comments array eventually approaches 16 MiB; writes to it start failing

// The fix: comments referenced in their own collection, indexed by postId
db.comments.insertOne({ postId: p, user: u, text: "..." })
db.comments.find({ postId: p }).sort({ createdAt: 1 })   // reads scale independently of the post document
1
This design has no bound on comments — it will work for a long time, then fail, with no warning in between beyond gradually rising latency.
6
Referencing removes the growth risk from the post document entirely — comments now scale as their own collection, with their own index.

Why this works: The fix is the same "reference an unbounded relationship" guidance from the embed-or-reference decision — this concept is about recognizing the concrete failure mode that guidance is protecting against, not a new rule.

Treating the 16 MiB limit as a distant, unlikely-to-matter constraint

Wrong

text
// "16 MiB is huge, we'll never hit that" — for an array with no cap, on a document with no expiration

Better

text
// For any embedded array: does something bound it? If not, assume it will eventually be a problem for the most active/popular documents

What you see: A production incident where writes to a specific "hot" document (the most popular post, the most active user) start failing, seemingly at random, while every other document is fine.

Why: 16 MiB feels large in the abstract, but an unbounded array grows fastest for exactly the documents an application cares most about — its most popular or most active records — making this a targeted risk on the most important data, not a generic edge case.

Growth risk, two stages

small document

fast reads/writes

growing document

gradually slower

16 MiB limit

writes start failing

  1. small document — fast reads/writes
  2. growing document — gradually slower
  3. 16 MiB limit — writes start failing

The two stages of document growth risk

The two stages of document growth risk
StageSymptomFix
Gradual degradationreads/writes slowly get slower as the document growsbound the array, or move it to a referenced collection before it matters
Hard limitwrites fail outright once 16 MiB is reachedthe same fix, now urgent — the document may also need to be split or migrated

Together

text
db.posts.updateOne({ _id: p }, { $push: { comments: newComment } })
// -> works for months, gets slower as comments grows, eventually:
// WriteError: BSONObj size: 16793601 (0x1005B01) is invalid. Size must be between 0 and 16793600(16MB)

Remember: 16 MiB is a hard document limit, not a guideline. Unbounded arrays degrade gradually before failing outright — design them out at schema time.

See also: bounded vs unbounded arrays · embedding tradeoffs

Advertisement