MongoDB Schema Design Patterns for Production
MongoDB's flexible document model is a double-edged sword. Done right, exceptional performance. Done wrong, slow queries and bloated documents.
Design Around Access Patterns
Unlike SQL where you normalize data, MongoDB schema design is driven by access patterns:
- What queries run most frequently?
- What is the read/write ratio?
- How does data change over time?
Embedding vs. Referencing
// Embedding: All data in one document
// Good for: Always accessed together, bounded size, one-to-few
{
_id: ObjectId("..."),
name: "John Doe",
addresses: [
{ type: "home", street: "123 Main St", city: "Austin" },
{ type: "work", street: "456 Corp Ave", city: "Austin" }
],
preferences: { notifications: true, theme: "dark" }
}
// Referencing: Foreign key style
// Good for: Large/unbounded data, accessed independently, many-to-many
{
_id: ObjectId("..."),
name: "John Doe",
order_ids: [ObjectId("..."), ObjectId("...")]
}
Bucket Pattern for Time-Series
Group measurements into time buckets to dramatically reduce document count:
// One document per measurement = 1M docs/sensor/year
// Bucket pattern: One doc per HOUR = 8,760 docs/sensor/year (114x fewer!)
{
sensor_id: "s1",
date: ISODate("2026-01-01T00:00:00"),
nMeasurements: 60,
measurements: [23.4, 23.5, 23.6],
summary: { min: 23.1, max: 23.8, avg: 23.5 }
}
// Efficient update: add to existing bucket
db.sensor_data.updateOne(
{
sensor_id: "s1",
date: ISODate("2026-01-01T00:00:00"),
nMeasurements: { $lt: 60 }
},
{
$push: { measurements: 23.7 },
$inc: { nMeasurements: 1 },
$min: { "summary.min": 23.7 },
$max: { "summary.max": 23.7 }
},
{ upsert: true }
)
Native Time-Series Collections (MongoDB 5.0+)
db.createCollection("sensor_readings", {
timeseries: {
timeField: "timestamp",
metaField: "sensor_id",
granularity: "minutes"
},
expireAfterSeconds: 7776000 // 90 days TTL
})
// Efficient hourly aggregation
db.sensor_readings.aggregate([
{ $match: { sensor_id: "temp_001", timestamp: { $gte: new Date("2026-01-01") } } },
{
$group: {
_id: { $dateTrunc: { date: "$timestamp", unit: "hour" } },
avgTemp: { $avg: "$temperature" },
count: { $sum: 1 }
}
},
{ $sort: { _id: 1 } }
])
The Outlier Pattern
Handle occasional documents that would violate your normal schema:
// Normal post: embedded comments
{ _id: ObjectId("post_1"), comments: [...up to 50], has_extra: false }
// Viral post: overflow to separate collection
{ _id: ObjectId("post_2"), comments: [...first 50], has_extra: true }
// Overflow collection for additional comments
{ post_id: ObjectId("post_2"), comments: [...next batch] }
async function getComments(postId) {
const post = await db.posts.findOne({ _id: postId });
if (!post.has_extra_comments) return post.comments;
const overflow = await db.overflow.find({ post_id: postId }).toArray();
return [...post.comments, ...overflow.flatMap(o => o.comments)];
}
Schema Validation
db.createCollection("users", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["email", "name", "created_at"],
properties: {
email: {
bsonType: "string",
pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}quot;
},
role: { enum: ["user", "admin", "moderator"] },
age: { bsonType: "int", minimum: 0, maximum: 150 }
}
}
},
validationAction: "error"
})
Indexing Best Practices
// Compound index for common queries
db.orders.createIndex({ user_id: 1, status: 1, created_at: -1 })
// Partial index: only active records
db.users.createIndex(
{ email: 1 },
{
partialFilterExpression: { deleted_at: { $exists: false } },
unique: true
}
)
// Text search with field weighting
db.articles.createIndex(
{ title: "text", content: "text" },
{ weights: { title: 10, content: 1 } }
)
// Profile query performance
db.orders.find({ user_id: "123" }).explain("executionStats")
// Look for: IXSCAN over COLLSCAN
// totalDocsExamined should be close to nReturned
Anti-Patterns to Avoid
// AVOID: Unbounded arrays (16MB document limit)
{ liked_posts: [/* millions of IDs */] }
// Fix: Separate collection for likes
// AVOID: Monotonically increasing shard key (creates write hotspot)
// Fix: Hash-based sharding
db.adminCommand({
shardCollection: "mydb.events",
key: { user_id: "hashed" }
})
Good MongoDB schema design means thinking about access patterns first, then choosing the right embedding/referencing strategy and indexes.