Filter concepts by levelShowing all levels.

MongoDB · Section 8

Schema Design Patterns

Level
advanced
Read
30 min
Concepts
4

Works through the six named schema design patterns — subset, bucket, extended reference, computed, attribute, outlier — in enough depth to recognize which fits a given symptom; how bucket document boundaries trade write cost against query cost; modeling unbounded event and history data as its own collection; and when a precomputed counter or summary is worth its maintenance cost.

This section

What is true here

  1. Each of the six patterns targets one specific, measurable schema cost — apply them against evidence, not speculatively.
  2. A bucket's time span is a schema decision: match it to the width of the app's dominant query range.
  3. Event, audit, and history data is unbounded by nature and belongs in its own collection, not an ever-growing array.
  4. A precomputed counter turns an expensive repeated read into a cheap field read, paid for with atomic increments and periodic reconciliation.

What you will be able to do

  • Match a schema symptom to the pattern that targets it
  • Choose a bucket interval based on the dominant query range, not a default
  • Model event/history/audit data as its own indexed collection instead of an embedded array
  • Decide when a counter or summary is worth precomputing, and maintain it safely under concurrent writes

The pattern set

All six named patterns, worked through with a concrete symptom-to-pattern match for each.

The six patterns, worked in detail

coreadvanced

Subset keeps only a needed slice of a large sub-document embedded. Bucket groups many small readings into fewer per-interval documents. Extended reference copies a few fields to skip a $lookup. Computed stores a value instead of recalculating it. Attribute turns sparse fields into a queryable array. Outlier handles the rare oversized document separately.

Think of it as

Each pattern targets exactly one measurable cost: subset targets working-set size, bucket targets per-document overhead at high write volume, extended reference targets join count, computed targets read-time CPU, attribute targets index count on sparse fields, and outlier targets the one document that would otherwise force a worse design for every other document.

text
// Each pattern is applied to the specific collection/field showing the symptom — not adopted project-wide

What we're doing: Work through the bucket pattern end to end: the problem it solves and the resulting document shape.

bucket-pattern-worked.txttext
// Without bucketing: one document per reading — millions of tiny documents per day
db.readings.insertOne({ sensorId: "s1", ts: ISODate("2026-08-21T10:00:03Z"), value: 21.4 })

// With bucketing: one document per sensor per hour, readings appended into an array
db.readings.updateOne(
  { sensorId: "s1", hour: ISODate("2026-08-21T10:00:00Z") },
  { $push: { measurements: { ts: ISODate("2026-08-21T10:00:03Z"), value: 21.4 } }, $inc: { count: 1 } },
  { upsert: true }
)
2
One document per reading means one index entry, one document header, and one write per reading — overhead that adds up at high ingestion rates.
6
Bucketing by hour cuts document count by roughly the average readings-per-bucket, trading a slightly more complex write for far less per-document overhead and often better query locality for "this sensor, this hour" reads.

Why this works: The bucket pattern is not just "batch writes" — it deliberately reshapes the schema around a natural grouping key (sensor + time interval) so both writes and the most common read (a time range for one sensor) land on a small number of documents instead of scanning many.

Picking a bucket interval without checking the read pattern it needs to serve

Wrong

text
// Bucketing by day when the dashboard almost always queries "the last hour"

Better

text
// Choose the bucket interval to match the dominant query range — hourly buckets for an hourly dashboard

What you see: A query for "the last hour" has to open and scan a bucket document containing an entire day of readings, most of them irrelevant to the query.

Why: The bucket size is itself a schema decision driven by access patterns, the same principle underlying every other pattern here — an interval mismatched to the dominant query range reintroduces the per-read overhead bucketing was meant to remove.

Symptom first, then the matching pattern

measured symptom

e.g. slow read, huge working set

matching pattern

one of the six

targeted fix

not a general rewrite

  1. measured symptom — e.g. slow read, huge working set
  2. matching pattern — one of the six
  3. targeted fix — not a general rewrite

Pattern → symptom it targets → what changes

Pattern → symptom it targets → what changes
PatternTargetsWhat changes
Subsetworking set too large to fit in memoryembed a bounded slice, reference the full set
Buckettoo many small documents, high per-doc overheadgroup into fewer, larger interval documents
Extended referencea $lookup on every read of a hot pathcopy the needed fields, skip the join
Computedthe same expensive calculation runs on every readstore the result, update it on write
Attributemany sparse or varying field names, hard to indexname/value pairs in one indexed array
Outlierone document type breaks the design for the restspecial-case the outlier, keep the common design simple

Together

text
// Attribute pattern: instead of a top-level field per (sparse, varying) product spec...
{ color: "red", weight_kg: 2 }         // fine for some products, but every category adds new fields

// ...store them as name/value pairs, indexed once:
{ specs: [ { k: "color", v: "red" }, { k: "weight_kg", v: 2 } ] }
db.products.createIndex({ "specs.k": 1, "specs.v": 1 })  // one index serves every spec name

Remember: Subset, bucket, extended reference, computed, attribute, outlier — six patterns, each targeting one specific, measurable symptom. Apply one at a time, against a measured cost, not speculatively.

See also: schema design patterns overview · time series bucketing

Advertisement

Time-series, events, and precomputation

Three patterns applied to their most common real-world use: bucketing, unbounded history, and cheap reads via precomputed summaries.

How bucket boundaries affect update and query cost

standardintermediate

A bucket's time span sets a direct trade: wider buckets mean fewer documents but each write touches a bigger document, and a narrow query range can still pull in an oversized bucket. Narrower buckets mean smaller, cheaper individual writes but more documents to scan for a wide query.

Think of it as

Think of bucket width as a knob between two costs that move in opposite directions: write/update cost per bucket rises with width (each write finds and rewrites a bigger document), while document count — and the overhead of opening many of them for a wide query — falls as width rises. There is no width that minimizes both at once; the right width matches the width of the queries the app actually runs.

text
// Choose bucket width ≈ the width of the app's most common query range, not the finest or coarsest possible interval

What we're doing: Show the same query at two different bucket widths, to make the mismatch cost concrete.

bucket-width-mismatch.txttext
// Daily buckets, dashboard queries "last hour":
db.readings.find({ sensorId: "s1", day: ISODate("2026-08-21") })
// -> loads a document holding an entire day of readings to serve an hour-wide query

// Hourly buckets, same query:
db.readings.find({ sensorId: "s1", hour: ISODate("2026-08-21T10:00:00Z") })
// -> loads exactly the bucket the query needs
2
A bucket wider than the query range forces MongoDB to load and filter within data the query does not need.
6
Matching the bucket width to the query range means the read touches only the data it actually needs.

Why this works: The document boundary is a physical unit of I/O — a query can only ever load whole documents, so a boundary drawn wider than the dominant query range always costs more than one drawn to match it, regardless of how well-indexed the field is.

Choosing one fixed bucket width for every use of a time-series collection

Wrong

text
// Hourly buckets for both a real-time "last 5 minutes" dashboard and a "last year" trend report, served from the same collection

Better

text
// Match bucket width to the dominant access pattern; consider a second, coarser aggregate (or a rollup collection) for very wide queries instead of forcing one width to serve both

What you see: One of the two access patterns is consistently expensive — either many tiny buckets scanned for the wide report, or a bucket far wider than needed loaded repeatedly for the real-time dashboard.

Why: A single bucket width cannot be simultaneously optimal for a narrow, high-frequency query and a wide, infrequent one — when both patterns matter, the schema often needs either a width tuned to the more frequent pattern or a separate precomputed rollup for the wide one.

Remember: Bucket width trades write cost against document count. Match it to the width of the app's dominant query range — a mismatched width costs on every read or every write, whichever direction it's off.

See also: the six named patterns · modeling event history without giant documents

Modeling event and history data without giant documents

standardintermediate

Event and history data (logs, audit trails, activity feeds) is a textbook unbounded array — it grows for the life of its parent. Model it as its own collection, one document per event, referencing the parent by id, rather than appending to an array on the parent forever.

Think of it as

A history is not a property of its parent the way a name or a status is — it is a stream that happens to be about the parent. Modeling it as a separate, append-only collection treats it as what it actually is: an ever-growing log, indexed by the parent it relates to and the time it happened, not a field that has to fit inside one document.

text
{ _id, parentId, type, at: ISODate(...), ...eventFields }  // one document per event, in its own collection

What we're doing: Show an order's status-change history modeled as its own collection instead of an ever-growing embedded array.

event-history-as-own-collection.txttext
// Unbounded, avoided: every status change appended to the order forever
db.orders.updateOne({ _id: 101 }, { $push: { statusHistory: { status: "shipped", at: new Date() } } })

// Modeled as its own collection instead:
db.orderEvents.insertOne({ orderId: 101, type: "status_changed", status: "shipped", at: new Date() })
db.orderEvents.createIndex({ orderId: 1, at: 1 })
db.orderEvents.find({ orderId: 101 }).sort({ at: 1 })  // an order's full history, on demand
2
This array has no natural ceiling — a long-lived order (returns, refunds, disputes) accumulates events for as long as it exists.
6
A separate, indexed collection scales with total event count across all orders, not with any single order document's size — and the order document itself stays small and fast to read/write.

Why this works: Separating the event stream from the parent document keeps the parent's own read/write cost constant regardless of how much history accumulates, while still making "this parent's full history" a fast, indexed query rather than a field that has to be loaded with every read of the parent.

Embedding history "because it's small right now" without checking whether it is genuinely bounded

Wrong

text
// A new feature's audit trail embedded directly on the entity, reasoned as "there are only a few events so far"

Better

text
// Model any history/audit/event stream as its own collection from the start — bounded-vs-unbounded reasoning applies from day one, not after the array becomes a problem

What you see: A production entity with an unusually long lifetime (a long-running order, a heavily-used account) develops a much larger document than typical, slowing every read and write of it.

Why: Event and history data is unbounded by its very nature — the fact that early data looks small is exactly the "small today, not actually bounded" trap bounded-vs-unbounded-arrays already warns about, and it is worth deciding correctly the first time rather than migrating later.

Remember: Event/history data is unbounded by nature — model it as its own collection (parent id + timestamp, indexed), not as a field that grows on the parent forever.

See also: bounded vs unbounded arrays · time series bucketing

Precomputing counters and summaries

standardintermediate

The computed pattern applied specifically to counts and summaries: instead of running an aggregation over many documents on every read (e.g. "how many likes does this post have"), maintain the number as a field, updated incrementally with $inc on every write that changes it.

Think of it as

A precomputed counter turns an O(n) read (scan or aggregate n related documents) into an O(1) read (load one field) by moving the cost to write time, where it is a cheap O(1) increment. This is worth it exactly when the read happens far more often than the write that would invalidate it — which is true for most "count of related things" fields.

text
db.<collection>.updateOne(<filter>, { $inc: { <counterField>: 1 } })  // atomic, safe under concurrent writes

What we're doing: Show a like-count field maintained atomically alongside the write that changes it, plus a periodic reconciliation check.

precomputed-counter-with-reconciliation.txttext
// Every like insert also increments the precomputed counter, atomically, in the same request:
db.likes.insertOne({ postId: 1, userId: 42 })
db.posts.updateOne({ _id: 1 }, { $inc: { likeCount: 1 } })

// Periodic reconciliation (e.g. nightly job) catches drift from any write path that skipped the $inc:
const real = db.likes.countDocuments({ postId: 1 })
db.posts.updateOne({ _id: 1 }, { $set: { likeCount: real } })
2
$inc is atomic and safe under concurrent likes — two simultaneous likes both land correctly, no lost update.
6
Reconciliation is the safety net for the real risk of this pattern: any write path (a bug, a bulk import, a manual fix) that changes the underlying data without also updating the counter.

Why this works: Precomputing trades a small, ongoing maintenance cost (remembering to increment, and periodically reconciling) for a large, repeated read-cost saving — the trade is worth it exactly because reads of a like count vastly outnumber the writes that change it.

Precomputing a counter without a reconciliation plan for drift

Wrong

text
// Maintaining likeCount via $inc everywhere, with no periodic check against the real underlying count

Better

text
// Add a periodic reconciliation job, and treat any large gap it finds as a sign that a write path is missing its $inc

What you see: A displayed count is quietly wrong for some documents — often noticed only when a user reports "this says 3 but I count 5."

Why: Every write path that changes the underlying data has to remember to also update the derived counter — a single missed path (a bulk delete, an admin tool, a bug fix that bypassed the normal write path) silently desyncs the two, and only reconciliation catches it.

Aggregate-on-read vs. precomputed

Aggregate-on-read vs. precomputed
ApproachRead costWrite costDrift risk
Aggregate on every readscans/counts related documents each timenone — no extra writenone — always exact
Precomputed counterone field readone $inc per relevant writepossible if a write path is missed

Together

text
// Aggregate on read (expensive at scale):
db.likes.countDocuments({ postId: 1 })

// Precomputed (cheap read, small write cost, needs discipline):
db.posts.updateOne({ _id: 1 }, { $inc: { likeCount: 1 } })
db.posts.findOne({ _id: 1 }, { likeCount: 1 })

Remember: Precompute a counter/summary when reads vastly outnumber the writes that change it — maintain it with atomic $inc, and add periodic reconciliation to catch drift from any write path that misses the increment.

See also: the six named patterns · update operators

Advertisement