MongoDBData Modeling Patterns Reference

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.

JS
// 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 } } } })
Tip
Use the attribute pattern when you'd otherwise need one index per optional field — that doesn't scale past a handful of variants. Attribute pattern needs just one.
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).

JS
{
  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 })
Note
Bucketing cuts index entries and document count by whatever your bucket size is (60x for a per-minute-reading, per-hour bucket), dramatically reducing storage and index overhead. MongoDB's native time-series collections (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).

JS
// 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.

JS
{
  _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.

JS
// 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 followers
Polymorphic 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.

JS
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.

JS
{ _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 -> v2
Subset 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.

JS
// 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

Note
These patterns compose — a real schema might use bucketing for readings, the computed pattern to precompute hourly averages, and schema versioning to evolve the bucket shape over time.