Schema Design Patterns
MongoDB schema design patterns are proven, reusable solutions to common data modeling challenges. Knowing these patterns helps you build performant and scalable schemas from the start, rather than discovering bottlenecks in production and scrambling to restructure live data.
1. The Bucket Pattern
The Bucket Pattern is ideal for IoT sensor readings, time-series events, and metrics. Instead of one document per measurement (millions of tiny documents), group N readings per document (one document per hour, per device). This reduces total document count, shrinks index size, and dramatically improves range query performance.
// ❌ One document per reading — millions of documents, bloated index
{ sensorId: "sensor-001", ts: ISODate("2024-06-15T14:00:00Z"), value: 22.1 }
{ sensorId: "sensor-001", ts: ISODate("2024-06-15T14:01:00Z"), value: 22.3 }
// ✅ Bucket readings by hour — far fewer documents
{
_id: ObjectId("..."),
sensorId: "sensor-001",
hour: ISODate("2024-06-15T14:00:00Z"),
readings: [
{ ts: ISODate("2024-06-15T14:00:00Z"), value: 22.1 },
{ ts: ISODate("2024-06-15T14:01:00Z"), value: 22.3 },
{ ts: ISODate("2024-06-15T14:02:00Z"), value: 22.5 }
// ... up to 60 readings per bucket
],
count: 60,
avg: 22.4
}2. The Computed Pattern
The Computed Pattern pre-computes and caches expensive aggregation results directly on the document. Instead of running a costly aggregation on every read, update the cached value atomically each time a new piece of data arrives.
// products collection — pre-computed rating stats
{
_id: ObjectId("prod1"),
name: "Mechanical Keyboard",
price: 129.99,
cachedRating: 4.7,
reviewCount: 342
}
// When a new review is submitted, update the cached values atomically
await db.collection('products').updateOne(
{ _id: productId },
{
$inc: { reviewCount: 1 },
$set: { cachedRating: newAverageRating }
}
)
// Reads are now instant — no aggregation pipeline needed at query time3. The Extended Reference Pattern
When you store a reference (ObjectId) to another collection, every display of that data
requires a $lookup (join). The Extended Reference Pattern eliminates hot-path lookups
by embedding the most frequently accessed fields from the referenced document.
// orders collection — embed key user fields alongside the reference
{
_id: ObjectId("order1"),
userId: ObjectId("user1"),
// Extended reference — copied from users collection
userName: "Alice Johnson",
userEmail: "alice@example.com",
// Order data
items: [{ productId: ObjectId("p1"), qty: 2, price: 79.99 }],
total: 159.98,
status: "shipped",
createdAt: ISODate("2024-06-15")
}
// Displaying an order list now requires zero $lookup operations4. The Outlier Pattern
The Outlier Pattern handles the case where most documents have small, bounded arrays, but an occasional "celebrity" document has a massive array that would exceed 16 MB. Keep the common case embedded and overflow the rest into a separate collection.
// products collection — most products have a handful of reviews
{
_id: ObjectId("prod1"),
name: "Viral Product",
reviews: [
{ userId: ObjectId("..."), rating: 5, text: "Amazing!" },
// ... first N reviews embedded
],
hasMore: true // flag indicating overflow exists
}
// product_reviews_overflow collection — handles the excess
{ _id: ObjectId("..."), productId: ObjectId("prod1"), userId: ObjectId("..."), rating: 4, text: "..." }
{ _id: ObjectId("..."), productId: ObjectId("prod1"), userId: ObjectId("..."), rating: 5, text: "..." }
// Application checks hasMore and queries overflow collection if needed5. The Polymorphic Pattern
When documents in a collection have different shapes (different fields) but share a common query interface, store them in one collection with a type discriminator field. This avoids the complexity of multiple collections and collection-per-type joins.
// vehicles collection — different shapes in the same collection
{ _id: ObjectId("v1"), type: "car", make: "Toyota", model: "Camry", doors: 4, fuelType: "hybrid" }
{ _id: ObjectId("v2"), type: "truck", make: "Ford", model: "F-150", payload: 1000, towingCapacity: 13000 }
{ _id: ObjectId("v3"), type: "bike", make: "Trek", model: "Domane", gears: 22, frameSize: "M" }
// Common queries work across all types
db.vehicles.find({ make: "Toyota" }) // find all Toyota vehicles
db.vehicles.find({ type: "truck" }) // find all trucks
// Application code branches on the type field
// if (vehicle.type === 'truck') { showTowingCapacity(vehicle.towingCapacity) }6. The Schema Versioning Pattern
As your application evolves, add a schemaVersion field to documents so the application
knows which shape to expect. Migrate documents lazily on read or run background migrations.
This avoids expensive big-bang schema migrations on large collections.
// V1 document — original shape
{ _id: ObjectId("u1"), schemaVersion: 1, name: "Alice Johnson", phone: "555-1234" }
// V2 document — phone split into a nested object
{ _id: ObjectId("u2"), schemaVersion: 2, name: "Bob Smith", phone: { number: "555-5678", type: "mobile" } }
// Application handles both versions
function getPhone(user) {
if (user.schemaVersion === 1) return user.phone // string
return user.phone.number // object
}
// Lazy migration — upgrade the document on next read
async function getUser(id) {
const user = await db.collection('users').findOne({ _id: id })
if (user.schemaVersion < 2) {
await migrateToV2(user)
}
return user
}7. The Attribute Pattern
When documents have many optional, sparse fields that all need to be indexed (e.g. product specifications that differ by category), a separate index per field becomes unmanageable. The Attribute Pattern converts those sparse fields into an array of key-value pairs, allowing a single compound index to cover all of them.
// ❌ Without Attribute Pattern — sparse fields, one index per searchable field
{
_id: ObjectId("prod1"),
name: "T-Shirt",
color: "red",
size: "M",
material: null,
weight: null,
voltage: null
}
// ✅ With Attribute Pattern — single compound index covers all attributes
{
_id: ObjectId("prod1"),
name: "T-Shirt",
specs: [
{ k: "color", v: "red" },
{ k: "size", v: "M" },
{ k: "material", v: "cotton" }
]
}
// One compound index covers all attribute queries
db.products.createIndex({ "specs.k": 1, "specs.v": 1 })
// Query any attribute efficiently
db.products.find({ specs: { $elemMatch: { k: "color", v: "red" } } })Choosing the Right Pattern
Use Case | Pattern |
|---|---|
IoT / time-series data | Bucket |
Expensive aggregations read frequently | Computed |
Avoid $lookup on hot read paths | Extended Reference |
Occasional documents with huge arrays | Outlier |
Multiple entity types in one collection | Polymorphic |
Schema evolution without downtime | Schema Versioning |
Sparse attributes needing unified search | Attribute |