MongoDB Performance Best Practices
A MongoDB query that feels instant on a 500-document test collection can grind to a halt once that collection holds 50 million documents in production. Performance in MongoDB is almost always about one thing: whether the server can find the documents it needs without looking at every document in the collection. This lesson teaches you how to read the query planner’s decisions, design indexes that actually get used, and spot the pipeline and schema patterns that quietly destroy performance as data grows.
Overview: How the Query Planner and Indexes Work
Every query you run is handed to MongoDB’s query planner before any documents are touched. The planner looks at the query shape (which fields you’re filtering, sorting, or projecting) and considers every index on the collection as a candidate access path. If a usable index exists, the planner can perform an index scan (IXSCAN): it walks a B-tree structure keyed on the indexed field(s) and jumps directly to matching entries, each of which points at the exact location of a document. If no usable index exists, MongoDB falls back to a collection scan (COLLSCAN): it reads every single document in the collection, in storage order, and tests each one against your filter. On a small collection the difference is invisible. On a large one, COLLSCAN turns a query that should take milliseconds into one that takes seconds or minutes, and it does this on every single execution, not just the first.
An index in MongoDB is a separate, sorted data structure (by default a B-tree) that stores the indexed field’s value alongside a pointer to the full document. Indexes make reads faster but are not free: every insert, update, or delete that touches an indexed field has to update every index that covers that field, and each index consumes RAM and disk space. This is why “just index everything” is not the answer — the goal is to index exactly the fields your real query patterns actually filter, sort, or join on.
When you have more than one field to filter on, a compound index (an index on multiple fields together) is usually far more effective than several single-field indexes, but only if you order the fields correctly. MongoDB’s own documentation calls this the ESR rule: put Equality fields first, then Sort fields, then Range fields. A compound index built in this order lets the planner narrow to an exact equality match, walk the index in the already-sorted order your query needs (avoiding an expensive in-memory sort), and then filter the remaining range condition — all within the same index traversal.
Syntax
The tools you’ll use constantly for performance work are createIndex() and explain().
db.<collection>.createIndex( <keyPattern>, <options> )
db.<collection>.find( <query> ).explain( <verbosity> )
db.<collection>.explain( <verbosity> ).aggregate( <pipeline> )
- keyPattern — an object mapping field names to
1(ascending) or-1(descending), e.g.{ status: 1, createdAt: -1 }. Field order defines the ESR structure of the index. - options — an object such as
{ unique: true }or{ name: "custom_name" }to control index behavior and naming. - verbosity — one of
"queryPlanner"(plan only, default),"executionStats"(plan plus actual document counts and timing — the one you’ll use most), or"allPlansExecution"(stats for every plan the optimizer considered).
Examples
Example 1: Spotting a collection scan
// orders has 500,000 documents, no index on status
db.orders.find({ status: "shipped" }).explain("executionStats")
{
queryPlanner: {
winningPlan: { stage: "COLLSCAN", filter: { status: { '$eq': 'shipped' } } }
},
executionStats: {
nReturned: 1200,
totalDocsExamined: 500000,
totalKeysExamined: 0,
executionTimeMillis: 341
}
}
The COLLSCAN stage and totalDocsExamined: 500000 tell the real story: to return 1,200 matching orders, MongoDB read all 500,000 documents and discarded 498,800 of them. Adding an index fixes this directly:
db.orders.createIndex({ status: 1 })
{ status_1: 'status_1' }
db.orders.find({ status: "shipped" }).explain("executionStats")
{
queryPlanner: {
winningPlan: { stage: "FETCH", inputStage: { stage: "IXSCAN", indexName: "status_1" } }
},
executionStats: {
nReturned: 1200,
totalDocsExamined: 1200,
totalKeysExamined: 1200,
executionTimeMillis: 4
}
}
Now totalDocsExamined equals nReturned — MongoDB examined only the documents it actually returned, via an IXSCAN followed by a FETCH to pull the full document for each matching index entry. Execution time dropped from 341ms to 4ms.
Example 2: Applying the ESR rule with a compound index
// Query: equality on status, then sort by createdAt
db.orders.createIndex({ status: 1, createdAt: -1 })
db.orders.find({ status: "shipped" })
.sort({ createdAt: -1 })
.explain("executionStats")
{
queryPlanner: {
winningPlan: {
stage: "FETCH",
inputStage: { stage: "IXSCAN", indexName: "status_1_createdAt_-1" }
}
},
executionStats: { nReturned: 1200, totalDocsExamined: 1200, executionTimeMillis: 5 }
}
Because createdAt is stored in descending order inside the index right after the status equality match, MongoDB reads matching entries in the exact order the sort requires. Note the absence of a SORT stage in the plan — if you had built the index as { createdAt: -1, status: 1 } instead (range/sort field before equality field, violating ESR), the planner could not use the index as cleanly for this query shape and would likely add a separate, memory-hungry SORT stage.
Example 3: Filtering before joining in aggregation
db.orders.explain("executionStats").aggregate([
{ $match: { status: "shipped", createdAt: { $gte: new Date("2026-01-01") } } },
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
{ $group: { _id: "$customer.region", totalRevenue: { $sum: "$total" } } }
])
{
stages: [
{ '$cursor': { queryPlanner: { winningPlan: { inputStage: { stage: 'IXSCAN', indexName: 'status_1_createdAt_-1' } } } } },
{ '$lookup': {} },
{ '$group': {} }
]
}
Placing $match first lets the aggregation’s initial $cursor stage use the status_1_createdAt_-1 index, shrinking the document stream before the expensive $lookup join and $group accumulation ever run. Every document that $match filters out here is one fewer document the pipeline has to join and group later.
How It Works Step by Step
- MongoDB parses the query shape and checks the query plan cache for a previously chosen winning plan matching that shape.
- If no cached plan exists, the planner generates candidate plans — one per usable index, plus a collection-scan fallback — and runs a brief trial (multi-plan competition) of each.
- The plan that returns the required results while examining the fewest documents/index keys within a short time budget is chosen as the winning plan and cached for future queries with the same shape.
- During aggregation, stages execute in the pipeline order you wrote them; MongoDB’s own aggregation optimizer will sometimes reorder or merge stages internally (for example moving a later
$matchearlier when it’s safe to do so), but you should never rely on that — write pipelines with cheap, index-backed filtering first. - On a write, every index covering a changed field is updated synchronously as part of the same operation, which is why excessive indexing slows down writes even though it speeds up reads.
Common Mistakes
Mistake 1: Querying a large collection with no supporting index
Wrong:
db.orders.find({ status: "shipped", region: "APAC" })
// No index on status or region — full COLLSCAN on every call
Corrected:
db.orders.createIndex({ status: 1, region: 1 })
db.orders.find({ status: "shipped", region: "APAC" })
Mistake 2: Running $lookup before filtering
Wrong (joins the full, unfiltered collection before narrowing it):
db.orders.aggregate([
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
{ $match: { status: "shipped" } }
])
Corrected (filter first, and project only the fields the join actually needs):
db.orders.aggregate([
{ $match: { status: "shipped" } },
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
{ $project: { "customer.name": 1, total: 1, status: 1 } }
])
Mistake 3: Building a compound index in the wrong field order
Wrong (range field before equality field breaks the ESR rule):
db.orders.createIndex({ createdAt: -1, status: 1 })
db.orders.find({ status: "shipped" }).sort({ createdAt: -1 })
Corrected (equality field first, matching how the query actually filters):
db.orders.createIndex({ status: 1, createdAt: -1 })
Best Practices
- Run
explain("executionStats")on any query touching more than a few thousand documents before it ships — check thattotalDocsExaminedis close tonReturned. - Follow the ESR rule (Equality, Sort, Range) when ordering fields in a compound index.
- Put
$match(and$limit, when correctness allows) as early as possible in an aggregation pipeline so later stages process fewer documents. - Use
$projectto drop unneeded fields before a$lookupor$group, reducing the data each stage has to move around. - Avoid unbounded arrays growing inside a single document (a running log of every event, for example) — the whole array is read and rewritten on every update, and documents have a 16MB cap.
- Don’t create an index “just in case” — every unused index still costs write latency and RAM; periodically review
db.collection.aggregate([{ $indexStats: {} }])and drop indexes with zero recent usage. - Ensure your working set (frequently accessed indexes and documents) fits in available RAM; heavy disk I/O for routine queries is a strong signal you need more memory, better indexes, or smaller documents.
- Use
db.setProfilingLevel(1, { slowms: 100 })in development or staging to log slow operations and find real-world hot spots instead of guessing.
Practice Exercises
- You have a
db.productscollection with 2 million documents. Users filter bycategory(equality) andprice(range), and results are sorted byratingdescending. Design the compound index using the ESR rule, and write thefind()call that would use it. - Take the aggregation from Example 3 and rewrite it to also cap results to orders from the last 30 days and project away every
customerfield exceptregionbefore the$group. Explain in one sentence why each change reduces work for the pipeline. - Run
explain("executionStats")on a query in your own database (or a sandbox one). If you seeCOLLSCANand a large gap betweentotalDocsExaminedandnReturned, create an appropriate index and re-runexplain()to confirm the stage changes toIXSCAN.
Summary
- Without a usable index, MongoDB performs a
COLLSCAN, examining every document in the collection for every query — this scales badly as collections grow. explain("executionStats")is the single most important diagnostic tool: comparetotalDocsExaminedtonReturnedand check whether the winning plan isIXSCANorCOLLSCAN.- Compound indexes should follow the ESR rule — Equality fields, then Sort fields, then Range fields — to let MongoDB avoid both extra scanning and in-memory sorts.
- In aggregation pipelines, push
$match(and projections) as early as possible, especially before$lookupand$group, to shrink the document stream before expensive stages run. - Indexes speed up reads but slow down writes and consume RAM — index deliberately based on real query patterns, not preemptively.
