MongoDB Schema Design Patterns
Beyond the basic embed-vs-reference decision, MongoDB's engineering team has cataloged a set of recurring, named design patterns — reusable solutions to problems that show up across almost every domain. Knowing them by name makes it much faster to recognize "oh, this is a bucket pattern problem" instead of reinventing a worse solution.
Attribute Pattern
Use when documents have many similar fields that vary per document, are sparse, or need to be queried/indexed as a set (e.g. product specifications that differ by category). Move the varying fields into a key/value array instead of top-level fields.
// Instead of dozens of sparse, category-specific top-level fields...
// { name: "Laptop", screenSizeInches: 14, batteryLifeHours: 10, ... }
// { name: "Shoe", size: 10, color: "black", ... }
// ...use the attribute pattern:
{
name: "Laptop",
specs: [
{ k: "screenSizeInches", v: 14 },
{ k: "batteryLifeHours", v: 10 }
]
}
// One index covers ALL attribute queries across every product type
db.products.createIndex({ "specs.k": 1, "specs.v": 1 })
db.products.find({ specs: { $elemMatch: { k: "screenSizeInches", v: { $gte: 13 } } } })Bucket Pattern
Use for time-series or IoT data — instead of one document per reading (millions of tiny documents), group readings into time-bounded "buckets" (e.g. one document per sensor per hour).
{
sensorId: "sensor-42",
hour: ISODate("2026-07-10T14:00:00Z"),
count: 3,
readings: [
{ ts: ISODate("2026-07-10T14:05:00Z"), tempC: 21.4 },
{ ts: ISODate("2026-07-10T14:20:00Z"), tempC: 21.6 },
{ ts: ISODate("2026-07-10T14:50:00Z"), tempC: 21.3 }
],
sumTempC: 64.3, // pairs well with the computed pattern below
minTempC: 21.3,
maxTempC: 21.6
}
db.readings.createIndex({ sensorId: 1, hour: 1 })timeseries option on createCollection) automate this pattern today.Computed Pattern
Use when a value is read far more often than the underlying data changes, and recomputing it on every read is wasteful (an aggregate like a running total, an average rating, a view counter).
// Instead of aggregating movie ratings on every page view...
db.reviews.aggregate([
{ $match: { movieId: ObjectId("m1") } },
{ $group: { _id: "$movieId", avgRating: { $avg: "$rating" }, count: { $sum: 1 } } }
])
// ...precompute and store it on the movie document, updated on writes
db.movies.updateOne(
{ _id: ObjectId("m1") },
{ $inc: { ratingCount: 1, ratingSum: 5 }, $set: { avgRating: 4.6 } }
)
// Reads become a single findOne — no aggregation on the hot path.Extended Reference Pattern
Duplicate just the handful of fields you display everywhere directly onto the referencing document, alongside the ID, so the common read path avoids a $lookup. Covered in depth on the Embedding vs Referencing page.
{
_id: ObjectId("order1"),
customer: { id: ObjectId("cust1"), name: "Acme Corp", tier: "gold" }
// full customer record still lives in the customers collection
}Outlier Pattern
Use when a small number of documents are exceptional outliers that would otherwise force a schema built for the common case to bloat for everyone (a celebrity account with 40 million followers, a viral post with a million comments). Keep the normal case embedded, and add an overflow flag + linked documents for the rare outliers.
// Normal author — followers embedded (common case, fast, cheap)
{ _id: ObjectId("a1"), name: "Regular User", followers: [/* up to a few thousand */] }
// Outlier author — flagged, overflow moved to a separate collection
{ _id: ObjectId("a2"), name: "Celebrity", followers: [/* first N only */], hasOverflow: true }
db.follower_overflow.find({ authorId: ObjectId("a2") }) // remaining followersPolymorphic Pattern
Use when a single collection stores documents of different but related "shapes" (different payment methods, different event types, different content blocks). Add a discriminator field and let each shape have its own fields; query on the discriminator.
db.payments.insertMany([
{ type: "credit_card", last4: "4242", expiry: "09/27" },
{ type: "paypal", email: "buyer@example.com" },
{ type: "bank_transfer", iban: "DE89..." }
])
db.payments.find({ type: "credit_card" })
// A partial index scoped per type is often useful here too —
// see the Unique & Partial Index page.Schema Versioning Pattern
Add a schemaVersion field so application code can support old and new document shapes side by side during a migration, instead of a risky big-bang rewrite of every document.
{ _id: ObjectId("u1"), schemaVersion: 1, name: "Alice Smith" }
{ _id: ObjectId("u2"), schemaVersion: 2, firstName: "Bob", lastName: "Jones" }
// Application reads branch on schemaVersion and normalize in code,
// while a background migration script gradually rewrites v1 -> v2Subset Pattern
Use when a document embeds a large array but most reads only need the first few items (a product's thousands of reviews, but the product page only shows the top 5). Embed a small, curated subset directly, and keep the full set in a referenced collection.
// Product document embeds only the 5 most helpful reviews
{
_id: ObjectId("p1"),
name: "Wireless Mouse",
topReviews: [ /* 5 most helpful, embedded for a fast product page */ ]
}
// Full review history lives in the reviews collection, queried on demand
db.reviews.find({ productId: ObjectId("p1") }).sort({ helpfulVotes: -1 })Pattern Cheat Sheet
Pattern | Solves |
|---|---|
Attribute | Many sparse/varying fields needing one shared index |
Bucket | Time-series / IoT — too many tiny documents |
Computed | Expensive aggregate recomputed on every read |
Extended reference | Avoiding $lookup for a few commonly-displayed fields |
Outlier | A few documents that break the schema built for the common case |
Polymorphic | One collection, multiple related but different shapes |
Schema versioning | Migrating a document shape without a big-bang rewrite |
Subset | Large embedded array where only the top-N is usually needed |