How it works
The explanation, with examples you can run.
From a collection scan to an index
On a collection of 200,001 orders, with no index on customerId:
db.orders.find({ customerId: 42 }).explain("executionStats")The full output is long. These are the fields that matter (the stage chain is read bottom-up, from executionStats.executionStages and its inputStages):
{ "winningPlan": "COLLSCAN", "nReturned": 32, "totalKeysExamined": 0, "totalDocsExamined": 200001, "executionTimeMillis": 58 }Every document was read to return 32. After db.orders.createIndex({ customerId: 1 }):
{ "winningPlan": "FETCH <- IXSCAN customerId_1", "nReturned": 32, "totalKeysExamined": 32, "totalDocsExamined": 32, "executionTimeMillis": 0 }IXSCAN walked the index to the 32 matching keys and FETCH loaded each document. Ratios close to 1:1 are what you want.
In-memory sorts and covered queries
Add .sort({ createdAt: -1 }) and the plan becomes SORT <- FETCH <- IXSCAN customerId_1. The index finds the documents but not in date order, so they are sorted in memory. For 32 documents that costs nothing; for 50,000 it is the slow part, and a sort stage that needs more than 100 MB spills to disk (allowed by default since 6.0). With an index on { customerId: 1, createdAt: -1 } the SORT stage disappears. If the projection asks only for fields in the index, MongoDB need not fetch documents at all:
db.orders.find({ customerId: 42 }, { _id: 0, customerId: 1, createdAt: 1 }).sort({ createdAt: -1 }){ "winningPlan": "PROJECTION_COVERED <- IXSCAN customerId_1_createdAt_-1", "nReturned": 32, "totalKeysExamined": 32, "totalDocsExamined": 0 }totalDocsExamined: 0 is a covered query. Exclude _id explicitly, or it has to be fetched.
How the plan is chosen
When several indexes could serve a query, the planner runs the candidate plans for a short trial, picks the one that produced results with the least work, and caches it for that query shape (the filter, sort and projection shape, not the values). From MongoDB 8.3 a cost-based ranker backs up this trial when it cannot decide. explain("allPlansExecution") shows the losers too. hint() forces an index, which is useful for comparing plans while you test, and risky to leave in application code. To find slow queries in the first place, use the slow query log (operations over 100 ms by default) or the database profiler.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.