MongoDB Schema Design: Embed vs Reference and the Patterns That Follow
Key takeaways
MongoDB schema design is fundamentally different from SQL — you model around your queries, not around normalization rules. This guide covers the embed-vs-reference decision, relationship patterns, and real-world schema patterns.
Why Schema Design Is Different in MongoDB
In SQL, you normalize to eliminate redundancy. In MongoDB, you denormalize for query performance — store related data together so a single document read answers the query.
The core question for every relationship: How will this data be accessed?
The most common way this goes wrong is a migration from SQL that maps each table to a collection one-to-one. The result has all the joins of the relational design, now done with $lookup or several application queries, and none of the benefits of either model. When I have seen this, the fix was not tuning but redesigning: list the queries the application actually runs, then shape documents so the frequent ones read a single document.
“Schemaless” also does not mean “no schema”. The schema still exists; it just lives in your application code and in the shape of the documents already written. Deciding it explicitly, and enforcing the parts that matter with validation (section 4), is what keeps a MongoDB database maintainable after a year of feature work.
Embedding vs. Referencing
Embed when:
- Data is always read together
- Embedded data is small and has a bounded size
- Relationship is “owned by” (user owns their addresses)
- Data isn’t shared across documents
// EMBEDDED: user with addresses
{
_id: ObjectId("..."),
name: "Alice",
email: "[email protected]",
addresses: [
{ type: "home", street: "123 Main St", city: "Seoul", zip: "04524" },
{ type: "work", street: "456 Tower Ave", city: "Seoul", zip: "06236" }
]
}
Single db.users.findOne({ _id: userId }) returns everything.
Reference when:
- Data is large or grows without bound
- Data is accessed independently
- Data is shared across many documents
- You need to update one piece of data in many places
// REFERENCED: post with author
// posts collection
{
_id: ObjectId("post-1"),
title: "MongoDB Schema Design",
authorId: ObjectId("user-1"), // reference
tags: ["mongodb", "database"]
}
// users collection
{
_id: ObjectId("user-1"),
name: "Alice",
email: "[email protected]"
}
To get post + author: $lookup or two separate queries.
A checklist for each relationship
| Question | Favors embedding | Favors referencing |
|---|---|---|
| Read together? | Almost always | Rarely, or only part of it |
| Updated together? | Usually in the same operation | Frequently on their own |
| How many children? | A few, with a known upper bound | Thousands, or unbounded |
| Shared by several parents? | No | Yes |
| Must stay consistent with the parent? | Yes: one-document updates are atomic | Can tolerate app logic or a transaction |
When the answers conflict, the access pattern that runs most often usually wins, and a hybrid (embed a summary, reference the full data) resolves the rest.
Performance trade-offs
| Aspect | Embedded | Referenced |
|---|---|---|
| Read on the common path | One find | Two finds or a $lookup |
| Write contention | Hotspot if many writers update the same document | Writes spread across documents |
| Document growth | Parent grows with its children; large documents cost more to read and rewrite | Parent stays small |
| Consistency | Single-document updates are atomic | Needs application logic or multi-document transactions |
| Duplication | Embedded copies must be kept in sync if the source changes | One source of truth |
Write contention is the trade-off people miss. Every update to an embedded array locks and rewrites that one document at the storage level, so a “post with embedded comments” becomes a bottleneck on a viral post where hundreds of users comment per second, even if each comment is small. Referencing spreads those writes across separate documents.
Relationship Patterns
One-to-One
Always embed unless the embedded document is large or rarely accessed together.
// Embed profile inside user (always needed together)
{
_id: ObjectId("..."),
email: "[email protected]",
profile: {
bio: "Software engineer",
avatar: "https://...",
location: "Seoul"
}
}
// Reference if profile is large or separately managed
{
_id: ObjectId("user-1"),
email: "[email protected]",
profileId: ObjectId("profile-1") // separate collection
}
One-to-Few (embed)
When the “many” side is small and bounded (e.g., ≤ 10 items), embed.
// Blog post with tags (bounded, small)
{
_id: ObjectId("..."),
title: "My Post",
tags: ["tech", "mongodb", "backend"]
}
// Order with line items (bounded, always needed together)
{
_id: ObjectId("..."),
userId: ObjectId("..."),
total: 129.99,
lineItems: [
{ productId: ObjectId("..."), name: "Widget", qty: 2, price: 49.99 },
{ productId: ObjectId("..."), name: "Gadget", qty: 1, price: 30.01 }
]
}
One-to-Many (reference on the “many” side)
When the “many” side can grow large (comments, orders, logs), put the reference on the child.
// posts collection
{ _id: ObjectId("post-1"), title: "My Post" }
// comments collection — reference points to parent
{ _id: ObjectId("..."), postId: ObjectId("post-1"), text: "Great post!", userId: ObjectId("...") }
{ _id: ObjectId("..."), postId: ObjectId("post-1"), text: "Thanks!", userId: ObjectId("...") }
// Query: get all comments for a post
db.comments.find({ postId: ObjectId("post-1") }).sort({ createdAt: -1 })
One-to-Many with partial embed (hybrid)
Store a subset of frequently accessed fields in the parent to avoid joins for common queries:
// Store the 3 most recent comments in the post (for display);
// all comments still live in their own collection
{
_id: ObjectId("post-1"),
title: "My Post",
commentCount: 247,
recentComments: [
{ userId: ObjectId("..."), username: "Bob", text: "Great!", createdAt: ISODate("...") },
{ userId: ObjectId("..."), username: "Carol", text: "Thanks!", createdAt: ISODate("...") }
]
}
Keeping the embedded list bounded is done in the same update that adds a comment, with $push, $each, $sort, and $slice:
db.posts.updateOne(
{ _id: postId },
{
$inc: { commentCount: 1 },
$push: {
recentComments: {
$each: [{ userId, username, text, createdAt: new Date() }],
$sort: { createdAt: -1 },
$slice: 3
}
}
}
)
The cost of this pattern is duplication: username is copied into every recent comment. If users can rename themselves, decide up front whether old snapshots may keep the old name (often acceptable for comments) or whether a background job must update them.
Many-to-Many
Use arrays of references on one or both sides depending on access patterns.
// Students enrolled in courses
// students collection
{
_id: ObjectId("student-1"),
name: "Alice",
enrolledCourseIds: [ObjectId("course-1"), ObjectId("course-2")]
}
// courses collection
{
_id: ObjectId("course-1"),
name: "MongoDB Fundamentals",
// Don't duplicate student array here unless needed
}
// Query students in a course
db.students.find({ enrolledCourseIds: ObjectId("course-1") })
// Query courses for a student
db.courses.find({ _id: { $in: student.enrolledCourseIds } })
Schema Patterns
Bucket Pattern
Group time-series data into buckets to reduce document count and enable efficient range queries.
Problem: Storing one document per sensor reading creates millions of tiny documents.
// NAIVE: one doc per reading (bad)
{ sensorId: "s1", timestamp: ISODate("2026-04-16T10:00:00"), value: 22.5 }
{ sensorId: "s1", timestamp: ISODate("2026-04-16T10:01:00"), value: 22.7 }
// ... 1440 documents per sensor per day
// BUCKET: one doc per hour
{
sensorId: "s1",
date: ISODate("2026-04-16"),
hour: 10,
readings: [
{ minute: 0, value: 22.5 },
{ minute: 1, value: 22.7 },
// ... up to 60 readings
],
count: 60,
avgValue: 22.6,
minValue: 22.1,
maxValue: 23.0
}
Benefits: 60× fewer documents, pre-computed aggregates, efficient range scans.
Outlier Pattern
Handle the rare case where a document would grow unbounded by splitting overflow to a separate collection.
// Product with reviews — most products have <50 reviews
{
_id: ObjectId("product-1"),
name: "Popular Gadget",
reviews: [ /* first 50 reviews embedded */ ],
reviewCount: 15000,
hasExtraReviews: true // flag signals overflow
}
// Overflow collection for products with many reviews
// product_reviews_overflow collection
{
productId: ObjectId("product-1"),
page: 2,
reviews: [ /* reviews 51-150 */ ]
}
Application checks hasExtraReviews and queries the overflow collection only when needed. The pattern optimizes for the common case: the 99% of products with few reviews stay a single read, and only the rare outlier pays for a second query.
Computed Pattern
Pre-compute expensive aggregations and store the result to avoid recalculating on every read.
// Instead of aggregating every request...
db.orders.aggregate([
{ $match: { customerId: ObjectId("...") } },
{ $group: { _id: null, total: { $sum: "$amount" }, count: { $sum: 1 } } }
])
// Store computed stats on the customer document
{
_id: ObjectId("customer-1"),
name: "Alice",
stats: {
totalOrders: 47,
totalSpent: 3847.50,
avgOrderValue: 81.86,
lastOrderDate: ISODate("2026-04-10")
}
}
// Update stats when a new order is placed
// (avgOrderValue can be derived from totalSpent / totalOrders at read time)
db.customers.updateOne(
{ _id: customerId },
{
$inc: { "stats.totalOrders": 1, "stats.totalSpent": orderAmount },
$set: { "stats.lastOrderDate": new Date() }
}
)
The computed values can drift from the source data if an order insert succeeds and the stats update fails. Either run both writes in a transaction, or accept eventual consistency and periodically recompute the stats from orders, which is what most systems do for counters like likes or views.
Schema Versioning Pattern
Add a schemaVersion field to handle schema evolution without downtime migrations.
// Version 1 (old documents)
{ _id: ..., schemaVersion: 1, name: "Alice Smith" }
// Version 2 (new documents)
{ _id: ..., schemaVersion: 2, firstName: "Alice", lastName: "Smith" }
// Application handles both
function getUserName(user) {
if (user.schemaVersion >= 2) {
return `${user.firstName} ${user.lastName}`;
}
return user.name; // fallback for v1
}
// Lazy migration: upgrade on read
async function getUser(id) {
const user = await db.users.findOne({ _id: id });
if (user && (user.schemaVersion ?? 1) < 2) {
const [firstName, ...rest] = user.name.split(' ');
await db.users.updateOne(
{ _id: id, schemaVersion: user.schemaVersion }, // skip if another request already migrated it
{ $set: { schemaVersion: 2, firstName, lastName: rest.join(' ') }, $unset: { name: "" } }
);
return { ...user, schemaVersion: 2, firstName, lastName: rest.join(' ') };
}
return user;
}
Indexing for Schema Patterns
Compound indexes for embedded fields
// Document
{
_id: ObjectId("..."),
name: "Alice",
address: { city: "Seoul", zip: "04524" }
}
// Index on embedded fields with dot notation
db.users.createIndex({ "address.city": 1, "address.zip": 1 })
Multikey indexes for arrays
// Automatically handles arrays — one index entry per array element
db.posts.createIndex({ tags: 1 })
// Query any element in array
db.posts.find({ tags: "mongodb" })
Multikey indexes grow with array length (a document with 200 tags adds 200 index entries), and a compound index can contain at most one array field. Both are another reason to keep embedded arrays bounded.
Tenant-leading compound indexes
In a multi-tenant application where every query filters by tenantId, put tenantId first in every compound index:
db.orders.createIndex({ tenantId: 1, status: 1, createdAt: -1 })
db.orders.find({ tenantId, status: "pending" }).sort({ createdAt: -1 })
This follows the usual order for compound indexes: equality fields first, then sort fields, then range fields. It also makes a forgotten tenantId filter show up as a collection scan in explain() rather than silently returning other tenants’ data quickly.
Text indexes for search
db.products.createIndex({ name: "text", description: "text" })
db.products.find({ $text: { $search: "wireless headphones" } })
Partial indexes — only index relevant documents
// Only index active users (saves space and write overhead)
db.users.createIndex(
{ email: 1 },
{ partialFilterExpression: { isActive: { $eq: true } } }
)
// Only index unpaid orders
db.orders.createIndex(
{ createdAt: 1 },
{ partialFilterExpression: { status: { $in: ["pending", "processing"] } } }
)
TTL index — auto-expire documents
// Sessions expire after 24 hours
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 86400 })
// Logs expire at a specific field value
db.logs.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
// Set expireAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) on insert
Schema validation
MongoDB can enforce the parts of the schema that must always hold, with a $jsonSchema validator on the collection:
db.createCollection("orders", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["userId", "status", "lineItems", "createdAt"],
properties: {
status: { enum: ["pending", "processing", "completed", "cancelled"] },
lineItems: {
bsonType: "array",
maxItems: 500, // guard against unbounded growth
items: {
bsonType: "object",
required: ["productId", "qty", "price"],
properties: { qty: { bsonType: "int", minimum: 1 } }
}
}
}
}
},
validationAction: "error" // "warn" logs violations instead of rejecting
})
Validation does not replace application-level checks, but it catches the bugs that otherwise surface months later, such as a code path that writes status: "Completed" or a string where a number was expected. On an existing collection, add it with collMod and start with validationAction: "warn" to see what existing data would fail.
Aggregation Pipeline
// Sales report: total revenue by category, last 30 days
db.orders.aggregate([
// Stage 1: filter recent orders
{ $match: {
status: "completed",
createdAt: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
}},
// Stage 2: unwind line items (array → multiple docs)
{ $unwind: "$lineItems" },
// Stage 3: group by category
{ $group: {
_id: "$lineItems.category",
revenue: { $sum: { $multiply: ["$lineItems.price", "$lineItems.qty"] } },
orderCount: { $sum: 1 }
}},
// Stage 4: sort and limit
{ $sort: { revenue: -1 } },
{ $limit: 10 },
// Stage 5: rename _id
{ $project: { category: "$_id", revenue: 1, orderCount: 1, _id: 0 } }
])
$lookup (join)
db.posts.aggregate([
{ $match: { status: "published" } },
{ $lookup: {
from: "users",
localField: "authorId",
foreignField: "_id",
as: "author"
}},
{ $unwind: "$author" },
{ $project: {
title: 1,
"author.name": 1,
"author.avatar": 1
}}
])
$lookup runs one lookup into users for every post that reaches that stage, so it needs an index on the foreign field (_id always has one) and benefits from a $match and $limit before it, not after. It is a good fit for reports and admin screens. If it appears on every request of a hot path, that is usually a sign that the author’s name and avatar should be embedded in the post as a snapshot.
Common Mistakes
Mistake 1: Embedding unbounded arrays
// AVOID: users array grows indefinitely
{
_id: ObjectId("post-1"),
title: "Popular Post",
likes: [
ObjectId("user-1"),
ObjectId("user-2"),
// ... could be millions
]
}
// FIX: separate likes collection or store count only
{
_id: ObjectId("post-1"),
title: "Popular Post",
likeCount: 15420
}
// Separate: db.likes.insertOne({ postId, userId, createdAt })
Mistake 2: Treating MongoDB like SQL (too many collections, no embedding)
// OVER-NORMALIZED (bad for MongoDB)
// users collection, profiles collection, addresses collection...
// Requires $lookup for every user read
// BETTER: embed what's always needed together
{
_id: ObjectId("..."),
email: "[email protected]",
profile: { bio: "...", avatar: "..." },
addresses: [{ type: "home", ... }]
}
Mistake 3: Missing indexes on foreign key fields
// If you store parentId references, always index them
db.comments.createIndex({ postId: 1, createdAt: -1 })
db.orders.createIndex({ userId: 1 })
Unlike most SQL databases’ foreign-key tooling, nothing in MongoDB reminds you. Without the index, db.comments.find({ postId }) scans the whole collection, which is fast in development with 200 comments and becomes the slowest query in production. Check with .explain("executionStats"): COLLSCAN or a totalDocsExamined far above nReturned means a missing or wrong index.
Mistake 4: Updating large embedded arrays by rewriting them
Reading a document, modifying an array in application code, and writing the whole document back is slow for large arrays and loses concurrent updates (last write wins). Update elements in place instead:
// Mark one line item as shipped without touching the others
db.orders.updateOne(
{ _id: orderId },
{ $set: { "lineItems.$[item].shipped": true } },
{ arrayFilters: [{ "item.productId": productId }] }
)
Real-World Cases
- E-commerce orders: the order header and its line items are embedded, because they are created together, read together, and the count is bounded. Each line item stores a snapshot of the product name and price at purchase time, while the product master stays in its own collection. The duplication here is a feature: the order must not change when the catalog price does.
- Social feeds and likes: posts are separate documents; likes live in their own collection (
{ postId, userId }with a unique index to prevent double likes), and the post carries a denormalizedlikeCountupdated with$inc. The count can be briefly out of step with the likes collection, which is an acceptable trade for not scanning likes on every feed render. - B2B multi-tenant SaaS: every document carries
tenantId, every index starts with it, and the data access layer adds it to every query so it cannot be forgotten. - IoT or metrics: readings use the bucket pattern (or MongoDB’s native time-series collections since 5.0, which bucket automatically), with the bucket size chosen so a document stays well under a few hundred KB.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Document approaching 16MB | Unbounded embedded array | Move children to their own collection, bucket, or apply the outlier pattern |
| Updates on one document are slow or conflict | Hot document with a large embedded array | Split the array out, or use arrayFilters instead of rewriting it |
| Reads slow despite an index | Working set larger than RAM, or fetching whole large documents | Project only needed fields; consider a covering index |
| Orphaned children after deletes | No referential integrity in MongoDB | Delete children in the same transaction, or use soft deletes and a cleanup job |
| Mixed field types in one collection | No validation, several code versions writing | Add $jsonSchema validation and a schemaVersion field |
Decision Guide
Is the relationship "owns" (user → address)?
→ Embed
Will the embedded array grow without bound?
→ Reference (or Bucket/Outlier pattern)
Is the embedded data accessed independently without the parent?
→ Reference
Is the same data referenced from many places?
→ Reference (avoids update anomalies)
Is this time-series data with high write volume?
→ Bucket pattern
Do you compute expensive aggregations on every read?
→ Computed pattern
Are you evolving the schema without a full migration?
→ Schema Versioning pattern
Related Articles
- MongoDB Guide — storage engine, replication, and operations
- Mongoose Guide — enforcing schemas from Node.js
- SQL vs NoSQL — when a document model fits at all
- PostgreSQL vs MySQL — the relational side of the trade-off