Designing for Your Query Patterns
In MongoDB, schema design is not a one-time exercise you finish before writing queries — it is the queries. Whether you embed an array inside a document, split it into its own collection, or add an index, the right answer depends entirely on how your application actually reads and writes that data. Get this wrong and every page load turns into a pile of extra round trips or a full collection scan; get it right and most of your important pages resolve in a single, indexed lookup.
Overview: Query-First Schema Design
MongoDB stores data as BSON documents, a binary superset of JSON that also supports dates, binary data, decimals, and the ObjectId type. Because a collection enforces no fixed schema, two documents in the same collection can legally have different shapes. That flexibility is often sold as MongoDB’s headline feature, but it does not mean schema design doesn’t matter — it means the schema is a decision you make deliberately, driven by your application’s access patterns, instead of one forced on you by a fixed table definition up front.
The structural choice you’ll make over and over is embed vs. reference. Embedding nests related data inside the parent document, so one read returns everything a query needs. Referencing stores an ObjectId that points at a document in another collection, and you fetch that related data with a second query or a $lookup aggregation stage. MongoDB does support $lookup, but it is not a free join the way an indexed SQL join can feel — each run has to find matching documents in the foreign collection for every document flowing through the pipeline. Leaning on it for data you could have embedded gives away a lot of MongoDB’s performance advantage.
The decision comes down to your real query patterns: What does your application ask for together, on the same page or in the same request? How often is the data written versus read? Does the related data grow without bound — years of sensor readings, thousands of comments — or stay small and bounded, like a shipping address or a handful of line items? Answer those questions for the queries you actually run, not hypothetical future ones, and the schema mostly designs itself.
| Signal | Favors embedding | Favors referencing |
|---|---|---|
| Read together? | Almost always fetched with the parent | Fetched independently, on its own page or query |
| Growth | Bounded — a fixed set of fields or a small array | Unbounded or large — grows indefinitely over time |
| Shared? | Belongs to exactly one parent document | Referenced by many different parent documents |
| Update frequency | Written once, rarely changes afterward | Changes independently of the parent’s lifecycle |
The Query-First Design Workflow
Before writing a single schema, list the queries your application will actually run, ranked by frequency and by how performance-sensitive they are. For each one, write down which fields it filters on (equality and range), which fields it sorts by, and whether it needs data that currently lives in more than one collection. This list — not an entity-relationship diagram — is your real design input.
For a typical order-management app that list might read: 1) load a customer’s order history, 2) show one order’s line items and shipping address, 3) look up live pricing and stock for a product across recent orders, 4) show a customer profile with their five most recent orders. Queries 1, 2, and 4 all revolve around the order document and run constantly; query 3 is comparatively rare and needs live, shared, mutable product data. That asymmetry is exactly what tells you to embed the shipping address and a price snapshot inside the order (serving queries 1, 2, and 4 from a single document), while referencing the product catalog by _id and reaching for $lookup only for the occasional query 3.
Once you know which fields your top queries filter and sort on, indexing follows the same logic: a compound index should list equality fields first, then sort fields, then range fields — commonly remembered as the ESR rule (Equality, Sort, Range). Example 3 below shows this in practice.
Examples
Example 1: Embedding data that’s always read together
A blog post and its comments are almost always displayed together, and a typical post accumulates a modest, bounded number of comments. Embedding them means loading a post is a single document fetch.
db.posts.insertOne({
title: "Understanding MongoDB Indexes",
author: "Priya Nair",
body: "Indexes are the single biggest lever for query performance in MongoDB...",
tags: ["indexes", "performance"],
createdAt: new Date("2026-07-01T09:00:00Z"),
comments: [
{ user: "dev_amit", text: "Great explanation, finally clicked for me.", createdAt: new Date("2026-07-01T10:15:00Z") },
{ user: "codegeek", text: "Would love a follow-up on compound indexes.", createdAt: new Date("2026-07-01T14:40:00Z") }
]
});
Output:
{
acknowledged: true,
insertedId: ObjectId("64f8a1b2c3d4e5f6a7b8c9e0")
}
db.posts.findOne({ title: "Understanding MongoDB Indexes" });
Output:
{
_id: ObjectId("64f8a1b2c3d4e5f6a7b8c9e0"),
title: "Understanding MongoDB Indexes",
author: "Priya Nair",
body: "Indexes are the single biggest lever for query performance in MongoDB...",
tags: [ "indexes", "performance" ],
createdAt: ISODate("2026-07-01T09:00:00.000Z"),
comments: [
{ user: "dev_amit", text: "Great explanation, finally clicked for me.", createdAt: ISODate("2026-07-01T10:15:00.000Z") },
{ user: "codegeek", text: "Would love a follow-up on compound indexes.", createdAt: ISODate("2026-07-01T14:40:00.000Z") }
]
}
One findOne call returns the post and every comment — no second query, no $lookup. This is the payoff of embedding data that’s always consumed together.
Example 2: Referencing shared data, snapshotting the rest
An order needs a shipping address and line-item details every time it’s displayed, but the live product catalog is shared across every customer’s orders and changes independently (price updates, restocks). The right design embeds a snapshot of what mattered at order time, and references the catalog by _id for the rare case where you need live product data.
db.orders.insertOne({
customerId: ObjectId("64f8a1b2c3d4e5f6a7b8c9d0"),
shippingAddress: { line1: "221B Baker Street", city: "Bengaluru", zip: "560001" },
items: [
{ productId: ObjectId("64f8a1b2c3d4e5f6a7b8c9d1"), name: "Wireless Mouse", price: 799, qty: 2 },
{ productId: ObjectId("64f8a1b2c3d4e5f6a7b8c9d2"), name: "USB-C Hub", price: 1499, qty: 1 }
],
status: "processing",
createdAt: new Date("2026-08-01T11:20:00Z")
});
Output:
{
acknowledged: true,
insertedId: ObjectId("64f8a1b2c3d4e5f6a7b8c9f0")
}
db.orders.find({ customerId: ObjectId("64f8a1b2c3d4e5f6a7b8c9d0") })
.sort({ createdAt: -1 });
Output:
[
{
_id: ObjectId("64f8a1b2c3d4e5f6a7b8c9f0"),
customerId: ObjectId("64f8a1b2c3d4e5f6a7b8c9d0"),
shippingAddress: { line1: "221B Baker Street", city: "Bengaluru", zip: "560001" },
items: [
{ productId: ObjectId("64f8a1b2c3d4e5f6a7b8c9d1"), name: "Wireless Mouse", price: 799, qty: 2 },
{ productId: ObjectId("64f8a1b2c3d4e5f6a7b8c9d2"), name: "USB-C Hub", price: 1499, qty: 1 }
],
status: "processing",
createdAt: ISODate("2026-08-01T11:20:00.000Z")
}
]
Both of an order’s most common queries — “show this order” and “list a customer’s orders” — resolve from the order document alone, with the address and a price snapshot embedded. Only when you specifically need live catalog data do you reach for $lookup:
db.orders.aggregate([
{ $match: { _id: ObjectId("64f8a1b2c3d4e5f6a7b8c9f0") } },
{ $lookup: {
from: "products",
localField: "items.productId",
foreignField: "_id",
as: "liveProductInfo"
} }
]);
Output:
[
{
_id: ObjectId("64f8a1b2c3d4e5f6a7b8c9f0"),
customerId: ObjectId("64f8a1b2c3d4e5f6a7b8c9d0"),
items: [
{ productId: ObjectId("64f8a1b2c3d4e5f6a7b8c9d1"), name: "Wireless Mouse", price: 799, qty: 2 },
{ productId: ObjectId("64f8a1b2c3d4e5f6a7b8c9d2"), name: "USB-C Hub", price: 1499, qty: 1 }
],
liveProductInfo: [
{ _id: ObjectId("64f8a1b2c3d4e5f6a7b8c9d1"), name: "Wireless Mouse", price: 749, stock: 128 },
{ _id: ObjectId("64f8a1b2c3d4e5f6a7b8c9d2"), name: "USB-C Hub", price: 1499, stock: 42 }
]
}
]
Notice the snapshot price (799) at order time differs from the live catalog price (749) — exactly the point of snapshotting: an order’s receipt should reflect what the customer actually paid, not today’s price.
Example 3: Indexing to match the query
Query 1 from the workflow above — “load a customer’s order history, newest first” — filters by customerId (equality) and sorts by createdAt (sort). Following the ESR rule, the index lists the equality field before the sort field:
db.orders.createIndex({ customerId: 1, createdAt: -1 });
db.orders.find({ customerId: ObjectId("64f8a1b2c3d4e5f6a7b8c9d0") })
.sort({ createdAt: -1 })
.explain("executionStats");
Output (trimmed):
{
queryPlanner: {
winningPlan: {
stage: "FETCH",
inputStage: {
stage: "IXSCAN",
indexName: "customerId_1_createdAt_-1",
direction: "forward"
}
}
},
executionStats: {
nReturned: 1,
totalKeysExamined: 1,
totalDocsExamined: 1,
executionTimeMillis: 0
}
}
The winning plan is an IXSCAN, not a COLLSCAN — MongoDB used the index to jump straight to matching documents already in createdAt order, so no separate in-memory sort was needed either. Always check explain() on a query you care about; a missing or wrongly-ordered index is invisible until you look.
How MongoDB Executes These Queries Step by Step
When a query runs, the query planner first checks whether an existing index can satisfy the filter. Without a matching index, MongoDB performs a COLLSCAN: it walks every document in the collection, in storage order, checking each one against the filter. On a large collection this is slow and gets slower as the collection grows — the classic “it worked in dev, it’s crawling in production” bug.
With the compound index from Example 3, the planner instead does an IXSCAN: it walks the B-tree index structure, uses the equality condition on customerId to jump straight to the relevant slice, and because createdAt is the next key in that same index, the matching entries are already in sorted order — no extra sort stage required. This is the mechanical reason the ESR rule works: equality fields narrow the index range, the sort field (if it’s next in the index) is served for free, and any range field comes last because a range condition can only use one contiguous slice of the index.
$lookup works differently — for every document that reaches that pipeline stage, MongoDB searches the foreign collection for documents matching the join condition (using an index on the foreign field if one exists, otherwise a scan per input document). That’s why $match stages that shrink the working set should run before an expensive $lookup, not after — joining against fewer documents is always cheaper than joining against all of them and filtering afterward.
Finally, remember that a write to a single document — including one with a deeply embedded array — is always atomic in MongoDB; no transaction is needed just because a document is complex. You only need a multi-document transaction when a single logical operation must succeed or fail across more than one document or collection, such as decrementing product stock in one collection while inserting an order in another.
Common Mistakes
Mistake 1: Designing like relational tables, then joining everything in application code
Splitting every relationship into its own collection — the SQL instinct — forces the application to run one query per related document, an N+1 problem MongoDB didn’t create but will happily let you build.
// Anti-pattern: one round trip per related document
const order = db.orders.findOne({ _id: ObjectId("64f8a1b2c3d4e5f6a7b8c9f0") });
order.items.forEach(item => {
const product = db.products.findOne({ _id: item.productId }); // extra query per item
print(product.name, product.price);
});
Each extra round trip adds network latency, and it scales linearly with the number of items — a 20-item order means 20 extra queries. Fetch the related data in one round trip with $lookup, or better, avoid needing live data at all by embedding a snapshot as in Example 2:
db.orders.aggregate([
{ $match: { _id: ObjectId("64f8a1b2c3d4e5f6a7b8c9f0") } },
{ $lookup: {
from: "products",
localField: "items.productId",
foreignField: "_id",
as: "productDetails"
} }
]);
// one round trip instead of one query per line item
Mistake 2: Embedding an array that grows without bound
Embedding is great for data that’s read together and stays small — but if that array keeps growing forever, embedding becomes a liability.
db.posts.updateOne(
{ _id: ObjectId("64f8a1b2c3d4e5f6a7b8c9e0") },
{ $push: { comments: { user: "newreader", text: "Nice post!", createdAt: new Date() } } }
);
// fine for a handful of comments, but a popular post can accumulate tens of
// thousands of them — the document keeps growing toward the 16MB BSON document
// limit, and every read or write moves the entire comments array over the wire
For a low-traffic blog this may never be a problem — but the moment a post can realistically accumulate thousands of comments, move them to their own collection, referenced by postId and indexed to serve the actual read pattern (“most recent comments for this post”):
db.comments.insertOne({
postId: ObjectId("64f8a1b2c3d4e5f6a7b8c9e0"),
user: "newreader",
text: "Nice post!",
createdAt: new Date()
});
db.comments.createIndex({ postId: 1, createdAt: -1 });
db.comments.find({ postId: ObjectId("64f8a1b2c3d4e5f6a7b8c9e0") })
.sort({ createdAt: -1 })
.limit(20);
Notice this is the same embedding decision from Example 1, revisited at a different scale — which is the core lesson of query-first design: the right schema depends on your actual data volume and access pattern, and it can change as your application grows.
Best Practices
- Start every schema design by listing and ranking your application’s real queries — not an entity diagram.
- Embed data that’s read together with its parent and stays bounded in size; reference data that’s large, shared across many parents, or grows without bound.
- Match compound indexes to your query using the ESR rule: Equality fields, then Sort fields, then Range fields.
- Run
explain("executionStats")on your important queries and confirm you seeIXSCAN, notCOLLSCAN. - Snapshot rarely-changing fields (a price, an address) at write time instead of always joining to live data you don’t need to be live.
- Put
$matchbefore$lookupin a pipeline so you join against the smallest possible set of documents. - Revisit the schema when query patterns or data volume change — a design that was right at launch can become wrong at scale.
Practice Exercises
- You’re building a support-ticket system where a ticket has many replies, and a busy ticket can accumulate hundreds of replies over months. Decide whether to embed replies in the ticket document or put them in a separate collection, and justify it using the read/write pattern.
- A
studentscollection needs a query that filters bygraduationYear(equality) and sorts bygpadescending. Write thecreateIndex()call that lets this query use an index scan for both the filter and the sort. - Given the
orderscollection from this lesson, write a query to find every order shipped tocity: "Bengaluru"in the last 30 days, and describe what index you’d add so it usesIXSCANinstead ofCOLLSCAN.
Summary
- Design MongoDB schemas around your application’s real, ranked query patterns — not a normalized entity diagram.
- Embed data that’s read together and bounded in size; reference data that’s large, shared, or grows without bound.
$lookupis not a free join — it re-searches the foreign collection per document, so filter before you join.- Compound indexes should follow the ESR rule: Equality, then Sort, then Range fields, to match how the planner uses them.
- Single-document writes are always atomic; reach for multi-document transactions only when multiple documents must succeed or fail together.
- The right schema can change as data volume grows — what’s fine to embed at launch may need to become a referenced collection later.
