Coming from SQL, the instinct is to normalise everything: users here, orders there, order items in a third place, join at read time. MongoDB lets you do that. It's usually the wrong call — and "just embed everything" is equally wrong.
The rule that actually works: model around how the data is read, not how it's shaped.
The six questions
Before choosing, I answer these. They resolve almost every case.
-
Is the child ever read without the parent? If comments are only ever shown on their post, they're a candidate for embedding. If they also appear in a "recent activity" feed, they want their own collection.
-
Is the relationship bounded? A product has a handful of variants — bounded. A post has unlimited comments — unbounded. Unbounded arrays are the single most common way to wreck a MongoDB collection.
-
How often does the child change relative to the parent? Embedded data is rewritten with the whole document. High-churn children inside a rarely-changing parent means constant document rewrites and index churn.
-
Does the child need to be queried independently? "All orders over ₹5,000 across all customers" is painful against embedded arrays and trivial against a real collection.
-
Is duplication acceptable? Denormalised copies go stale. Sometimes that's fine (an order should keep the price as it was at purchase). Sometimes it's a bug factory.
-
How large does the document get? 16 MB is the hard ceiling, but you'll feel the pain long before that. Every read pulls the whole document off disk.
Embed when the data is owned
Embed when the child has no independent life. Address on a user, line items on an order, config on a workspace:
// One read serves the entire order page — no joins, no round trips.
{
_id: ObjectId("..."),
customerId: ObjectId("..."),
status: "shipped",
placedAt: ISODate("2026-04-11T09:12:00Z"),
shippingAddress: {
line1: "42 Ferguson Road",
city: "Pune",
state: "MH",
pincode: "411004"
},
items: [
{ sku: "GC-500", name: "Gift Card ₹500", qty: 2, unitPrice: 500 },
{ sku: "GC-1000", name: "Gift Card ₹1000", qty: 1, unitPrice: 1000 }
],
total: 2000
}Note what's happening with items: the name and price are copied, not referenced. That's deliberate. If the product is renamed or repriced next month, this order must still show what the customer actually bought. Here, denormalisation is a correctness requirement, not an optimisation.
Also note that items is bounded — nobody puts 50,000 line items on an order.
Reference when the entity is independent
// posts
{ _id: ObjectId("p1"), title: "…", slug: "…", authorId: ObjectId("u1") }
// comments
{ _id: ObjectId("c1"), postId: ObjectId("p1"), body: "…", createdAt: ISODate(…) }Comments get their own collection because they're unbounded, queried on their own ("latest comments across the site"), moderated individually, and paginated.
Fetch them with a $lookup when you genuinely need them joined:
db.posts.aggregate([
{ $match: { slug: "mongodb-schema-design-embed-or-reference" } },
{
$lookup: {
from: "comments",
let: { postId: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$postId", "$$postId"] } } },
{ $sort: { createdAt: -1 } },
{ $limit: 20 } // never join unbounded
],
as: "recentComments"
}
}
]);The $limit inside the sub-pipeline is not optional. A $lookup without one is a loaded gun.
The unbounded array anti-pattern
This is the mistake that hurts most, because it works beautifully in development:
// ✗ Fine at 10 comments. A disaster at 10,000.
{
_id: ObjectId("p1"),
title: "…",
comments: [ /* grows forever */ ]
}What breaks, in order:
- Every read of the post pulls every comment off disk, even for a page that shows none of them.
- Every
$pushrewrites the document. Once it outgrows its allocated space, the storage engine moves it. - Indexes on array fields grow with the array. A post with 10,000 comments generates 10,000 index entries.
- At 16 MB, writes start failing in production, and the fix is a migration under pressure.
If you're already here, the bucket pattern is the escape hatch — fixed-size chunks that keep documents small while preserving locality:
{
postId: ObjectId("p1"),
bucket: 3,
count: 100, // cap enforced on write
comments: [ /* exactly 100 */ ]
}Time-series data (metrics, logs, events) is the classic use — one bucket per hour rather than one document per event.
The extended reference pattern
Pure referencing means a join on every render. Pure embedding means unbounded growth. The middle ground: reference the entity, embed the fields you actually display.
// comments
{
_id: ObjectId("c1"),
postId: ObjectId("p1"),
body: "Great write-up.",
author: {
_id: ObjectId("u1"), // source of truth
name: "Aditya Bhavar", // duplicated for display
avatarUrl: "/u/1.png"
},
createdAt: ISODate("2026-04-19T11:02:00Z")
}Rendering a comment thread is now a single query with no $lookup. The cost is staleness: if a user changes their display name, old comments keep the old one.
Whether that's acceptable is a product decision, not a technical one. If it isn't, fan out the update on write — renames are rare, comment reads are constant. Optimise for the frequent operation.
Indexes come from the schema, not after it
Once the shape is settled, the indexes follow directly. The rule is ESR — Equality, Sort, Range, in that order:
// Query: comments for a post, newest first
db.comments.find({ postId: id }).sort({ createdAt: -1 });
// Index: equality field first, sort field second
db.comments.createIndex({ postId: 1, createdAt: -1 });Get the order wrong and MongoDB does an in-memory sort, which fails outright past 32 MB. Verify rather than assume:
db.comments.find({ postId: id }).sort({ createdAt: -1 }).explain("executionStats");You want IXSCAN, not COLLSCAN, and totalDocsExamined close to nReturned. If you're examining 40,000 documents to return 20, the index isn't doing its job.
The short version
- Embed when data is owned, bounded, and read together.
- Reference when data is independent, unbounded, or queried on its own.
- Extend the reference with display fields when you'd otherwise
$lookupon every read. - Never let an array grow without a ceiling.
- Design for reads. Writes are rarer and you control them.
Model for the query you'll run a million times, not the one you'll run once.