The fundamental decision that shapes your entire data model
Schema design in MongoDB comes down to one question: should we embed related data inside a document, or keep it separate and reference it? This decision will affect query performance, write patterns, data consistency, and how your application scales. We've learned this lesson repeatedly in production—getting it wrong early means painful migrations later. Today we're walking through the framework that helps you make the right choice, every time.
Understanding the embed vs. reference trade-off
Before we dive into specific cases, let's clarify what we're choosing between. Embedding means nesting related data directly inside a parent document. Referencing means storing only an ID and fetching the related document separately. Each approach has a cost:
- Embedding: Faster reads (one query instead of multiple), automatic atomicity within the document, but larger document size and no sharing across parents.
- Referencing: Smaller documents, data reuse across parents, easier independent updates, but requires multiple queries or application logic to join.
MongoDB is not a relational database, but it's also not a document database that ignores relationships. The art is knowing which relationships deserve embedding and which need referencing.
Embed when — Data is read together and the child belongs to one parent
Embed data inside a document when two things are true: the child data is always accessed together with the parent, and it belongs to exactly one parent. This is the most common case and usually the right default.
Real-world example: An e-commerce order with line items. When you fetch an order, you almost always need its items. Those items don't exist on their own—they're specific to that order. Embedding makes sense.
db.orders.insertOne({
_id: ObjectId("507f1f77bcf86cd799439011"),
orderNumber: "ORD-2024-001",
customerId: ObjectId("507f1f77bcf86cd799439012"),
status: "shipped",
createdAt: ISODate("2024-01-15T10:30:00Z"),
items: [
{
productId: ObjectId("507f1f77bcf86cd799439013"),
sku: "WIDGET-A1",
quantity: 2,
unitPrice: 29.99,
totalPrice: 59.98
},
{
productId: ObjectId("507f1f77bcf86cd799439014"),
sku: "GADGET-B2",
quantity: 1,
unitPrice: 99.99,
totalPrice: 99.99
}
],
shippingAddress: {
street: "123 Main St",
city: "Portland",
state: "OR",
zip: "97201"
},
totalAmount: 159.97
})
When you query this order, you get everything you need in one request. MongoDB's document structure aligns with how you think about the data: an order contains its items and address. No joins, no secondary queries.
The key signal: if you never need the child without the parent, and you never need the child related to a different parent, embedding is likely correct.
Reference when — Data is shared, large, or grows without limit
Use references when the related data has one of these characteristics: it's shared across multiple parents, it's so large that duplication becomes wasteful, or it grows independently of the parent.
Shared data example: Product catalog in e-commerce. A product exists in its own right and is referenced from multiple orders, wishlists, and search results. Embedding product details in every order would duplicate data, making updates a nightmare.
// Order document with product references
db.orders.insertOne({
_id: ObjectId("507f1f77bcf86cd799439011"),
orderNumber: "ORD-2024-001",
customerId: ObjectId("507f1f77bcf86cd799439012"),
items: [
{
productId: ObjectId("507f1f77bcf86cd799439013"),
quantity: 2,
pricePaidPerUnit: 29.99 // Snapshot of price at purchase time
},
{
productId: ObjectId("507f1f77bcf86cd799439014"),
quantity: 1,
pricePaidPerUnit: 99.99
}
],
createdAt: ISODate("2024-01-15T10:30:00Z")
})
// Separate products collection
db.products.insertOne({
_id: ObjectId("507f1f77bcf86cd799439013"),
sku: "WIDGET-A1",
name: "Blue Widget",
description: "A quality widget for everyday use",
currentPrice: 34.99,
inventory: 1250,
category: ObjectId("507f1f77bcf86cd799439020")
})
Notice we store the price paid at purchase time in the order (snapshot), while the product's current price lives in the products collection. This separation lets product data be independently updated without affecting order history.
Large data example: A user profile that contains a high-resolution image, biography, and preferences. If you embed all of this in every document that references the user, queries become slow. Reference the user ID and fetch the full profile only when needed.
Unbounded arrays — Never embed arrays that can grow forever
This is where many MongoDB schemas fail in production. Never, ever embed an array that can grow without limit. MongoDB documents have a 16MB size limit, and more importantly, unbounded growth creates unpredictable memory and performance patterns.
The classic mistake: Embedding all comments directly in a post document.
// DON'T DO THIS
db.posts.insertOne({
_id: ObjectId("507f1f77bcf86cd799439011"),
title: "Why MongoDB matters",
body: "...",
author: ObjectId("507f1f77bcf86cd799439012"),
comments: [
// Could be 0, could be 100,000
{ userId: ObjectId("..."), text: "Great post!", createdAt: ISODate("...") },
{ userId: ObjectId("..."), text: "Disagree here...", createdAt: ISODate("...") },
// ... potentially unlimited more comments
]
})
If a post goes viral with 500,000 comments, that document becomes massive and slow to load. Every fetch of the post must load all comments, even if you only want to display the first 10.
The correct approach: Reference from the child to the parent.
// Post document (stays small)
db.posts.insertOne({
_id: ObjectId("507f1f77bcf86cd799439011"),
title: "Why MongoDB matters",
body: "...",
author: ObjectId("507f1f77bcf86cd799439012"),
createdAt: ISODate("2024-01-15T10:30:00Z"),
commentCount: 523 // Cached for quick access
})
// Comments collection (unbounded growth is fine here)
db.comments.insertMany([
{
_id: ObjectId("507f1f77bcf86cd799439013"),
postId: ObjectId("507f1f77bcf86cd799439011"),
userId: ObjectId("507f1f77bcf86cd799439012"),
text: "Great post!",
createdAt: ISODate("2024-01-15T11:00:00Z")
},
{
_id: ObjectId("507f1f77bcf86cd799439014"),
postId: ObjectId("507f1f77bcf86cd799439011"),
userId: ObjectId("507f1f77bcf86cd799439013"),
text: "Disagree here...",
createdAt: ISODate("2024-01-15T11:15:00Z")
}
])
// Query: fetch post and its first 10 comments
db.posts.findOne({ _id: ObjectId("507f1f77bcf86cd799439011") })
db.comments.find({ postId: ObjectId("507f1f77bcf86cd799439011") }).limit(10).sort({ createdAt: -1 })
Now the post document stays lean and fast. Comments are stored separately and can scale to any size. You fetch comments independently with pagination, which is what users expect anyway.
The rule: if you're asking "how many of these could there be?", and the answer is "unbounded" or "potentially millions", use references from child to parent.
Quick guide — Match the relationship to the approach
| Relationship Type | Characteristics | Approach | Example |
|---|---|---|---|
| One-to-few | Parent has small, fixed number of children (2-10) | Embed | Order → Items, User → Phone numbers |
| One-to-many | Parent has more children but still bounded (10-1000) | Embed or reference (depends on access pattern) | Author → Published articles, Store → SKUs |
| One-to-huge | Unbounded growth or very large array potential | Reference from child | Post → Comments, Article → Page views |
| Many-to-many | Children belong to multiple parents | Arrays of references | Students ↔ Courses, Users ↔ Teams |
One-to-few example (embed): A user's phone numbers. Most users have 1-3 phone numbers. Embed them directly in the user document.
One-to-many example (choose based on access): An author with 50 published articles. If you almost always fetch the author and display a summary of their articles on their profile page, embed abbreviated article references. If articles are accessed and managed independently, use references.
Many-to-many example (array of references): A student enrolled in multiple courses, and each course has multiple students. Store an array of course IDs in the student document and an array of student IDs in the course document.
// Student document
db.students.insertOne({
_id: ObjectId("507f1f77bcf86cd799439011"),
name: "Alice Johnson",
email: "alice@example.com",
courseIds: [
ObjectId("507f1f77bcf86cd799439020"),
ObjectId("507f1f77bcf86cd799439021"),
ObjectId("507f1f77bcf86cd799439022")
]
})
// Course document
db.courses.insertOne({
_id: ObjectId("507f1f77bcf86cd799439020"),
name: "Advanced MongoDB",
instructor: "Bob Smith",
studentIds: [
ObjectId("507f1f77bcf86cd799439011"),
ObjectId("507f1f77bcf86cd799439012"),
ObjectId("507f1f77bcf86cd799439013")
]
})
Common mistakes
Mistake 1: Premature normalization. Developers coming from relational databases often over-normalize MongoDB schemas. They reference everything to stay DRY, then end up with dozens of queries to construct a single logical object. MongoDB's embedding capability is a feature, not something to avoid.
Mistake 2: Ignoring growth potential. You embed an array of 20 items thinking that's safe, but business requirements change and suddenly you're trying to store 50,000 items in that array. Plan for the lifecycle of the relationship, not just today's data.
Mistake 3: Duplicating mutable data. If you embed product information in every order, but then the product details change, you're stuck. Store only immutable snapshots (like price at purchase time) or use references for data that changes independently.
Mistake 4: Forgotten indexes on reference fields. If you reference another document by ID, you'll query by that ID. Index those reference fields or your lookups will be slow.
// Always index reference fields
db.comments.createIndex({ postId: 1 })
db.orders.createIndex({ customerId: 1 })
db.enrollments.createIndex({ studentId: 1, courseId: 1 })
Key takeaways
- Embed by default when data is accessed together, belongs to one parent, and is bounded in size. This takes advantage of MongoDB's document model and keeps queries fast.
- Reference when data is shared across multiple parents, when it's independently mutable, or when it can grow without limit. Use references from child to parent for unbounded arrays.
- Never embed unbounded arrays. Comments, logs, events, transactions—anything that can grow indefinitely should reference from child to parent or live in a separate collection.
- Cache counts when useful. Store `commentCount` on the post if you frequently display it. Update the cache atomically alongside the actual data.
- Index reference fields. If you're querying by a foreign key, index it. This is easy to forget and causes performance problems later.
- Think about access patterns. The right schema is the one that serves your most common queries efficiently. Profile real usage and adjust if needed.
- Document your model. Write down why you chose to embed or reference each relationship. Your future self (and your team) will thank you when requirements change.






Comments
Loading comments…