# MongoDB Schema Design: When Embedding Beats Referencing (and When It Doesn't)
TL;DR: Embed data you always read together and that has a bounded size (an order and its line items); reference data that grows without limit, is written independently, or is shared across many parent documents (a blog post's comments, a user's org memberships). Most real schemas end up doing both in the same document via the extended reference pattern.
MongoDB's document model gets pitched as schema-flexible, and it is — but "flexible" doesn't mean "no decisions to make." The single biggest decision in any MongoDB data model is still: do you embed a related piece of data inside the parent document, or store it separately and reference it by _id? Get it wrong and you either end up running N+1 queries against a database that has no native joins culture, or you build a document that grows past MongoDB's 16MB per-document limit and starts throwing BSONObjectTooLarge errors at write time (MongoDB limits reference).
This isn't an abstract modeling exercise. Below are two real document shapes — one where embedding is clearly correct, one where it clearly isn't — plus the pattern MongoDB's own data modeling documentation recommends for the messy middle ground (MongoDB data modeling best practices).
Start from the query, not the entity
Relational schema design starts from entities and normalizes. MongoDB schema design starts from access patterns: what does the application read on its hottest path, and what does it read together? If two pieces of data are almost always fetched in the same request, embedding avoids a round trip. If they're read independently, at different frequencies, or by different parts of the system, embedding just means you're loading data you don't need.
Case 1: embedding — an order and its line items
An e-commerce order is the textbook embedding case: the line items, shipping address, and payment summary are created together, read together on the order confirmation and order history pages, and never queried independently of their parent order. There's also a natural upper bound — nobody puts 50,000 line items in one order.
{
"_id": "ord_9f2a1c",
"customerId": "cus_4471",
"status": "PAID",
"createdAt": "2026-09-18T10:32:00Z",
"shippingAddress": {
"line1": "221B Baker Street",
"city": "London",
"postalCode": "NW1 6XE",
"country": "GB"
},
"lineItems": [
{
"sku": "WH-MUG-BLK",
"name": "Ceramic Mug",
"unitPriceCents": 1499,
"quantity": 2,
"totalCents": 2998
},
{
"sku": "WH-TSHIRT-M",
"name": "Logo T-Shirt (M)",
"unitPriceCents": 2499,
"quantity": 1,
"totalCents": 2499
}
],
"subtotalCents": 5497,
"taxCents": 440,
"totalCents": 5937,
"payment": {
"method": "card",
"last4": "4242",
"processor": "stripe",
"chargeId": "ch_1P2q3R"
}
}One findOne({ _id: "ord_9f2a1c" }) gets everything the order page needs. No $lookup, no second round trip, no risk of the line items drifting out of sync with the order total because they're written atomically in a single document. This is embedding doing exactly what it's good at: co-located reads, bounded growth, single-writer updates.
Case 2: referencing — an article and its comments
Now take a blog article and its comments. This looks superficially similar (a parent with a list of children) but fails every test that made the order/line-item case work:
- Unbounded growth. A popular article can accumulate thousands of comments. Embedded in the article document, that pushes toward the 16MB ceiling and, well before that, makes every article fetch slower as the array grows — MongoDB has to read the whole document off disk even if you only render the first 20 comments.
- Independent write pattern. Comments are written by many different users, at high frequency, completely decoupled from edits to the article itself. Embedding them means every new comment triggers a write to the same document other readers are hitting, increasing contention.
- Independent read pattern. You often want comments without the article body (e.g., a "recent activity" feed), or paginated separately from the article page.
Here referencing is the right call:
// articles collection
{
"_id": "art_7b13e0",
"slug": "mongodb-schema-design",
"title": "MongoDB Schema Design: When Embedding Beats Referencing",
"authorId": "usr_1042",
"publishedAt": "2026-09-24T09:00:00Z",
"commentCount": 3182
}// comments collection
{
"_id": "cmt_a91f",
"articleId": "art_7b13e0",
"authorId": "usr_5589",
"body": "The extended reference section is the part most tutorials skip.",
"createdAt": "2026-09-24T14:12:00Z"
}// Fetch a page of comments for an article, newest first
db.comments
.find({ articleId: "art_7b13e0" })
.sort({ createdAt: -1 })
.limit(20);
// Supporting index — created once, used by every article's comment page
db.comments.createIndex({ articleId: 1, createdAt: -1 });Note the commentCount field sitting on the article document — that's a precomputed counter (updated via $inc when a comment is inserted), not the comments themselves. It's a cheap way to show "3,182 comments" on the article page without a COUNT query or embedding the comments to get the number.
The middle ground: extended reference pattern
Most real schemas aren't purely one or the other. The extended reference pattern — documented by MongoDB as one of its standard schema design patterns — embeds only the small, rarely-changing subset of a referenced document's fields that you need for display, while keeping the full document (and its _id) elsewhere for anything that needs the rest (MongoDB data modeling best practices).
Applied to the comment example, instead of a bare authorId, you embed the tiny slice of the author's profile the UI actually renders:
{
"_id": "cmt_a91f",
"articleId": "art_7b13e0",
"author": {
"id": "usr_5589",
"displayName": "Priya N.",
"avatarUrl": "https://cdn.wise-hustlers.com/avatars/usr_5589.webp"
},
"body": "The extended reference section is the part most tutorials skip.",
"createdAt": "2026-09-24T14:12:00Z"
}Rendering a comment list now takes zero $lookup calls. The tradeoff, and it's a real one: if a user changes their display name, every comment they've ever written has a stale copy until you run an update. That's fine for data that changes rarely (display name, avatar) and wrong for data that changes often (account balance, live status) — don't extended-reference anything you'd hate to have to backfill.
Decision table
| Signal | Favors embedding | Favors referencing |
|---|---|---|
| Growth | Bounded, known max size | Unbounded or large |
| Read pattern | Always read with parent | Often read independently |
| Write pattern | Written together, low write concurrency | Written by many actors independently |
| Cardinality | One-to-few | One-to-many or many-to-many |
| Consistency need | Must be atomically consistent with parent | Eventual consistency acceptable |
| Document size risk | Well under 16MB even at max growth | Would approach/exceed 16MB embedded |
Common mistakes
1. Embedding "just in case." Teams migrating from relational databases often embed everything reachable from an entity because that's what a join would have returned. This is how order documents quietly grow a customer sub-document with the customer's entire order history nested inside it.
2. Ignoring the 16MB ceiling until production. It rarely shows up in dev with 10 test records; it shows up months later on the one customer with 40,000 orders. Model for the tail case, not the average.
3. Referencing everything and paying for it in `$lookup` cost at scale. $lookup works, but it's not a free join — it's most efficient when the foreign field is indexed and the joined collection is reasonably sized; unindexed or high-fanout $lookups are a common source of slow aggregation pipelines.
4. Not indexing the reference field. If you're storing articleId on every comment, { articleId: 1 } (or a compound index matching your sort, as above) is not optional — without it, every comment page fetch is a collection scan.
Schema decisions like these compound over a codebase's lifetime, which is why it's worth getting the access-pattern analysis right before the collections fill up rather than after — a full re-model on a live production dataset is a much bigger job than the original design call. If you're evaluating this for a system already carrying real traffic, that's the kind of architecture review Wise Hustlers' custom software team does as part of scoping a build.
FAQ
Is embedding always faster than referencing in MongoDB?
For reads that need the full embedded document, yes — one document fetch beats a fetch plus a $lookup or a second query. But embedding an array that keeps growing makes every read of the parent document slower over time, even for fields unrelated to that array, because MongoDB reads the whole document. Faster on day one doesn't mean faster at scale.
What happens if a document exceeds the 16MB limit?
The write fails with a BSONObjectTooLarge error; MongoDB will not silently truncate or split the document (MongoDB limits reference). If you genuinely need to store something larger than 16MB as a single object (e.g., a video file), use GridFS rather than trying to fit it in a document.
Can I change my mind later and switch from embedding to referencing?
Yes, but it's a migration, not a config flag — you'll write a script to peel the embedded sub-documents into their own collection, backfill reference IDs, and update every query and write path that touched the old shape. It's far cheaper to model the growth pattern correctly up front than to migrate a live collection.
Does the extended reference pattern break data consistency?
It trades strict consistency for read performance on a specific, small set of fields. That's acceptable for slow-changing display data (names, thumbnails) and a bad idea for anything that must always reflect the current source of truth (prices, permissions, balances) — for those, reference and look up live instead.