Filter concepts by levelShowing all levels.

MongoDB · Section 14

Query Planning

Level
advanced
Read
35 min
Concepts
7

How MongoDB's query planner discovers a winning plan empirically — running candidate plans (one per usable index) through a short trial and caching whichever returned the most results for the least work — the execution stage tree (COLLSCAN, IXSCAN, FETCH, SORT) that explain() reveals, the three explain() verbosity levels and what each costs to run, reading rejected plans practically, and the crucial distinction between an index being used at all and an index actually fitting the query well.

MongoDB overview

What is true here

  1. The planner runs plausible candidate plans in a short trial and caches whichever produced the most results for the least work.
  2. COLLSCAN and IXSCAN are the literal explain() stage names for "checked every document" vs. "walked an index" — usually nested under FETCH/SORT.
  3. explain() has three verbosity levels trading off real execution cost: plan-only, executionStats, and allPlansExecution.
  4. rejectedPlans (via allPlansExecution) usually confirms the winner correctly did less work — compare stats before assuming a problem.
  5. IXSCAN confirms an index was used, not that it fits the query well — compare totalKeysExamined to nReturned to know which is true.

What you will be able to do

  • Explain how the query planner discovers and caches a winning plan
  • Read an explain() stage tree bottom-up and identify COLLSCAN, IXSCAN, FETCH, and SORT
  • Choose the right explain() verbosity level for a given investigation
  • Interpret rejectedPlans without assuming every rejection signals a problem
  • Distinguish "an index is used" from "the index fits this query well" using the scan-to-return ratio

How the planner chooses

The trial-based selection process, the two core stage names, and the fuller stage tree explain() reveals.

What the query planner does

coreintermediate

Before running a query, MongoDB's query planner considers every index that could help, runs the plausible candidates for a short trial, and picks whichever returned the most results for the least work. That choice is then cached and reused for future queries of the same shape.

Think of it as

The planner behaves like someone trying several routes on a short test drive before committing to one for a regular commute — it does not reason abstractly about which route "should" be fastest, it tries the realistic candidates briefly and picks the one that performed best. This is why the winning plan can be understood but is not something you configure directly — it is discovered.

text
db.<collection>.find({...}).explain("executionStats")   // shows the winning plan, bypassing the cache

What we're doing: Show two candidate plans (two different indexes) being evaluated, and the trial-based reasoning behind the winner.

candidate-plan-selection.txttext
// Two indexes exist on this collection: { status: 1 } and { status: 1, createdAt: -1 }
db.orders.find({ status: "pending" }).sort({ createdAt: -1 })

// The planner tries both as candidates in a short trial:
// - { status: 1 } needs an in-memory sort after fetching matches
// - { status: 1, createdAt: -1 } returns already-sorted results — wins the trial, gets cached
1
Both indexes could technically serve this query, which is exactly why the planner needs to choose between real candidates rather than there being one obvious answer.
5
The compound index avoids an extra in-memory sort step, so it produces the requested results with less work — that is what the trial period is measuring, not an abstract judgment about index design.

Why this works: Understanding that the planner discovers the winner empirically, rather than reasoning about it from index definitions alone, explains why adding or dropping an index can change a query's plan even when the query itself never changes.

Assuming the "obviously better" index is always the one MongoDB picks

Wrong

text
// Assuming the compound index always wins without checking explain(), because it "should" be better

Better

text
// Check explain() to see the actual winning plan — the trial-based selection can surprise you, especially with a stale cached plan

What you see: A query performs worse than expected despite a "better" index existing, because the plan cache is still serving an older winner from before conditions changed.

Why: The planner's choice is based on a real trial and then cached — it is not re-evaluated on every single query, so a plan that made sense earlier can persist past a point where a different index would now do less work, until the cache entry is invalidated.

From candidates to a cached winner

candidate plans

one per usable index

trial run

most results, least work

winning plan

cached for reuse

  1. candidate plans — one per usable index
  2. trial run — most results, least work
  3. winning plan — cached for reuse

Remember: The planner runs plausible candidate plans in a short trial, picks the one returning the most results for the least work, and caches that winner for future queries of the same shape. explain() always bypasses the cache.

See also: collscan vs ixscan · explain basics

COLLSCAN vs. IXSCAN

standardbeginner

These are the two stage names explain() shows for how MongoDB locates candidate documents. COLLSCAN checks every document in the collection in turn. IXSCAN walks a sorted index structure, examining only the entries that could match.

Think of it as

These names are what section 10's "why indexes exist" concept was already describing — COLLSCAN and IXSCAN are simply the literal stage names explain() prints for the scan-vs-index distinction, not a new idea. Seeing one or the other in real output is how that earlier concept becomes checkable rather than theoretical.

text
db.<collection>.find({...}).explain("executionStats").executionStats.executionStages.stage

What we're doing: Show the same query's stage name changing from COLLSCAN to IXSCAN after adding the relevant index.

stage-name-before-after.txttext
// No index on email:
db.users.find({ email: "a@x.com" }).explain().queryPlanner.winningPlan.stage
// -> "COLLSCAN"

// After db.users.createIndex({ email: 1 }):
db.users.find({ email: "a@x.com" }).explain().queryPlanner.winningPlan.stage
// -> "FETCH" (with an "IXSCAN" inputStage underneath)
1
Without a usable index, the winning plan's top-level stage is COLLSCAN — there was no candidate index-based plan to compete with it.
4
With the index in place, the winning plan becomes FETCH (retrieving the matched documents) with IXSCAN nested underneath it — the two stages typically appear together, not IXSCAN alone.

Why this works: The stage tree, not a single stage name, is what tells the real story — FETCH-over-IXSCAN is the normal shape for an indexed equality query, and expecting to see IXSCAN as the sole top-level stage would miss this.

Searching explain() output for the string "COLLSCAN" only at the top level and missing a nested one

Wrong

text
// Checking only winningPlan.stage, missing a COLLSCAN buried inside an inputStages array for a multi-collection or $or query

Better

text
// Walk the full inputStage/inputStages tree, or search the whole explain() output for "COLLSCAN" rather than checking one field

What you see: A query believed to be fully indexed still performs poorly, because one branch of an $or (or one collection in a $lookup) is actually a collection scan that a single top-level check missed.

Why: explain() output is a tree, and a query with multiple branches (an $or, a $lookup) can have some branches using IXSCAN and others falling back to COLLSCAN — only inspecting the very top stage can miss a scan buried deeper in the tree.

Remember: COLLSCAN and IXSCAN are the literal explain() stage names for "checked every document" vs. "walked an index." They usually appear nested (FETCH over IXSCAN) — check the whole stage tree, not just the top-level stage.

See also: the query planner · execution stages · why indexes exist

FETCH, SORT, and other execution stages

standardintermediate

Beyond COLLSCAN/IXSCAN: FETCH retrieves full documents after an index scan locates their positions, and SORT orders results in memory when no index provides the needed order. Stages nest in a tree, each one processing its child stage's output.

Think of it as

Each stage does one job and hands its output to the stage above it — IXSCAN finds candidate locations, FETCH turns those into real documents, SORT reorders them if needed, and so on up the tree. Reading an explain() tree is reading this pipeline from the leaves (data access) up to the root (final result), and any single stage can be the one adding unnecessary cost.

text
// Read the stage tree bottom-up: leaf stages access data, parent stages process what their child returned

What we're doing: Trace a full stage tree for a query needing both an index scan and an in-memory sort, explaining what each layer contributes.

stage-tree-trace.txttext
db.orders.find({ status: "pending" }).sort({ total: -1 })
// with only { status: 1 } indexed (not total):

// winningPlan.stage: "SORT"
//   inputStage.stage: "FETCH"
//     inputStage.stage: "IXSCAN" (on { status: 1 })
1
This query filters by status (indexed) but sorts by total (not indexed) — a shape that needs more than a single scan stage to satisfy.
5
Reading bottom-up: IXSCAN finds matching status entries, FETCH retrieves the full documents, and SORT — the root stage — orders them by total in memory, since no index provided that order already.

Why this works: This is the concrete evidence for the ESR ordering principle (compound-index-design section) — a compound index on { status: 1, total: -1 } would let SORT disappear from this tree entirely, because the index itself would already return status-filtered results in total order.

Seeing IXSCAN in the stage tree and assuming the query needs no further optimization

Wrong

text
// "It has IXSCAN, so it's using an index — done" — without checking for a SORT stage still present above it

Better

text
// Check the full tree for a SORT stage specifically — an indexed filter can still need an expensive in-memory sort layered on top

What you see: A query "using an index" is still slow on large result sets, because an in-memory SORT stage sits above the IXSCAN, sorting every matched document before returning any.

Why: IXSCAN only confirms the filter used an index — it says nothing about whether the sort also did, and a SORT stage anywhere in the tree means MongoDB is doing extra, potentially expensive work that a better-ordered compound index could eliminate.

Common stages and what triggers them

Common stages and what triggers them
StageJobAppears when
COLLSCANchecks every documentno usable index for this query
IXSCANwalks an indexa usable index exists
FETCHretrieves full documentsthe query needs fields beyond what the index alone provides
SORTorders results in memoryno index provides the requested sort order already

Together

text
db.orders.find({ status: "pending" }, { customerId: 1 }).explain().queryPlanner.winningPlan
// -> if the index already covers { status, customerId }, this can skip FETCH entirely (a covered query)

Remember: FETCH retrieves full documents after a scan; SORT orders results in memory when no index provides the needed order. Read the stage tree bottom-up — IXSCAN alone does not mean the whole query is optimal.

See also: collscan vs ixscan · esr index reasoning

Advertisement

Investigating with explain()

How candidates and winners are chosen, using explain() itself, reading rejected plans practically, and the crucial used-vs-well-aligned distinction.

How a winning plan is selected among candidates

standardintermediate

A candidate plan is one possible way to answer the query, generated per usable index. MongoDB's classic multi-planner runs the plausible candidates in parallel for a short trial and picks whichever produced the most results for the least work — that becomes the winning plan, cached for reuse.

Think of it as

Think of "candidate" and "winning" as roles in a competition, not fixed properties of an index — the same index might be the winning plan for one query shape and a losing candidate for a slightly different one. What makes a plan win is empirical performance during the trial, not anything intrinsic to the index itself.

text
db.<collection>.find({...}).explain("allPlansExecution")  // shows candidate plans, not just the winner

What we're doing: Use allPlansExecution to see the rejected candidates alongside the winner, not just the final choice.

seeing-all-candidates.txttext
db.orders.find({ status: "pending", region: "west" }).explain("allPlansExecution")

// .queryPlanner.winningPlan       -> the plan that won the trial
// .queryPlanner.rejectedPlans     -> the candidates that lost, and why (fewer results per unit of work)
1
"allPlansExecution" mode runs and reports on every candidate plan's trial performance, not just the eventual winner.
4
rejectedPlans shows what else was considered — useful for understanding why a particular index was NOT chosen, not only confirming which one was.

Why this works: Seeing the rejected candidates, not just the winner, is often what actually explains a surprising plan choice — an index that "should" have won sometimes shows up in rejectedPlans with a concrete, checkable reason it lost the trial.

Only ever looking at the winning plan and never checking why an expected index lost

Wrong

text
// db.orders.find({...}).explain("executionStats")  — winning plan only, no visibility into what else was tried

Better

text
// db.orders.find({...}).explain("allPlansExecution")  — shows rejectedPlans alongside the winner, when the choice is surprising

What you see: A specific index that seems like it should be ideal never appears as the winning plan, and there is no visibility into why without deliberately requesting the full candidate set.

Why: The default explain() verbosity focuses on the winner for brevity — allPlansExecution is the tool for the specific, less common need of understanding the selection process itself, not just its outcome.

Remember: A candidate plan exists per usable index; the classic planner runs candidates in a short trial and picks the one with the best results-per-work ratio. Use explain("allPlansExecution") to see rejected candidates, not just the winner.

See also: the query planner · rejected plans and plan selection

Using explain() to investigate a query

corebeginner

explain() runs the query planner (and optionally the query itself) and reports what it did instead of returning documents: which plan won, its stage tree, and — with "executionStats" — real numbers like how many documents were examined versus returned. Section 15 covers reading those numbers in depth; this is the tool itself.

Think of it as

explain() is a query in "dry run plus report" mode — it answers "what would happen, and how much work would it take" instead of "give me the data." Three verbosity levels trade off how much it actually executes: queryPlanner (plan only, no execution), executionStats (executes and reports real numbers), and allPlansExecution (executes every candidate, not just the winner).

text
db.<collection>.find({...}).explain("executionStats")

What we're doing: Show the three verbosity levels applied to the same query, and what each adds over the previous one.

explain-verbosity-levels.txttext
db.orders.find({ status: "pending" }).explain()
// -> plan only: which stage tree would run, no execution, no real numbers

db.orders.find({ status: "pending" }).explain("executionStats")
// -> actually runs it: adds nReturned, totalDocsExamined, executionTimeMillis for the winning plan

db.orders.find({ status: "pending" }).explain("allPlansExecution")
// -> adds the same real numbers for every candidate plan that was tried, not just the winner
1
The default level answers "what plan would run" without spending any execution time or touching real data.
5
"executionStats" is the level most performance investigation actually needs — real, measured numbers for the plan that actually won.

Why this works: Choosing the right verbosity level matters because executionStats and allPlansExecution actually run the query (or every candidate), which has a real cost on a large or slow query — reaching for allPlansExecution by default when queryPlanner or executionStats would answer the question is unnecessary extra work.

Reading only queryPlanner-level output and drawing conclusions that need real execution numbers

Wrong

text
// db.orders.find({...}).explain()  — then judging "is this slow" from the plan shape alone, with no nReturned or totalDocsExamined

Better

text
// db.orders.find({...}).explain("executionStats")  — get the real numbers before judging whether a plan is actually a problem

What you see: A plan that "looks fine" from its stage names turns out to be scanning far more documents than it returns, a fact only visible once execution actually runs and reports real counts.

Why: The default queryPlanner verbosity describes the shape of the plan, not its actual cost — the scan-to-return ratio that section 15 uses to diagnose real performance problems only exists once the query has actually executed and reported real numbers.

Three verbosity levels, three trade-offs

queryPlanner

plan only, no execution

executionStats

runs it, real numbers

allPlansExecution

every candidate, not just the winner

  1. queryPlanner — plan only, no execution
  2. executionStats — runs it, real numbers
  3. allPlansExecution — every candidate, not just the winner

Remember: explain() default: plan only, no execution. "executionStats": actually runs it, real numbers. "allPlansExecution": every candidate, not just the winner. Always bypasses the plan cache.

See also: the query planner · explain verbosity modes

Reading rejected plans in practice

standardadvanced

When a plan you expected to win instead shows up under rejectedPlans, the practical response is to compare its execution stats against the actual winner — often the winner genuinely did less work for this query shape, and the "expected" plan was a reasonable guess that the trial disproved.

Think of it as

A rejected plan is not a mistake in the query or a bug — it is a losing entry in a fair, small competition where results-per-work decided the outcome. Reading rejectedPlans practically means asking "did this plan lose because of something the trial correctly measured, or because the trial period was too short/unrepresentative for this specific case" — both happen, and only checking the real numbers tells you which.

text
db.<collection>.find({...}).explain("allPlansExecution").queryPlanner.rejectedPlans

What we're doing: Compare a rejected plan's stats against the winner to see a genuinely correct rejection, not a surprising one.

comparing-rejected-vs-winner.txttext
db.orders.find({ status: "pending", region: "west" }).explain("allPlansExecution")

// winningPlan (index on { region, status }): examined 120 keys, returned 118 docs
// rejectedPlans[0] (index on { status }): examined 4,500 keys, returned 118 docs — same result, far more work
1
allPlansExecution is what makes this comparison possible in the first place — the default verbosity would only show the winner.
4
This rejection is genuinely correct: the { status }-only index had to examine 4,500 candidates to find the same 118 matches the { region, status } index found by examining only 120 — the trial measured a real, large difference.

Why this works: This is what "practical" means for rejected plans — not reading rejectedPlans as an error report, but as data that either confirms the winner deserved to win or points at a specific index that is not serving this query shape as well as it appears to on paper.

Treating every rejected plan as evidence something is wrong, without comparing its actual stats to the winner

Wrong

text
// Seeing a familiar index in rejectedPlans and assuming that means the index is broken or unused, without checking why it lost

Better

text
// Compare the rejected plan's own execution stats to the winner's — most rejections are the trial correctly finding a better option, not a problem

What you see: Time spent investigating a "problem" with an index that was, in fact, correctly and reasonably outperformed by a better-suited one for this specific query shape.

Why: Rejection is the normal, expected outcome for every candidate except the winner — it only becomes worth investigating further when the rejected plan's own stats look close to or better than the winner's despite losing, which is a specific, checkable condition rather than a default assumption.

Remember: rejectedPlans (from allPlansExecution) shows losing candidates' real stats — compare them to the winner's before assuming a rejection means something is wrong. Most rejections are the trial correctly finding a better option.

See also: winning and candidate plans · explain basics

Using an index does not guarantee a fast query

coreintermediate

IXSCAN in the plan just means MongoDB walked an index — it says nothing about how well that index matches the query. An index examining thousands of entries to return a handful of results is technically "using an index" while still doing almost as much work as a scan.

Think of it as

This is the same lesson section 10 and 11's prefix rule and ESR concepts already establish, restated as its own explicit fact because it is such a common misconception: "has an index" and "has the right index for this query" are different claims, and only the second one predicts real performance. The scan-to-return ratio in explain() is the concrete, checkable evidence for which one is actually true.

text
// Compare totalKeysExamined to nReturned in explain("executionStats") — do not stop at "the stage says IXSCAN"

What we're doing: Show a poorly-aligned index producing a large scan-to-return ratio despite genuinely using IXSCAN.

ixscan-but-misaligned.txttext
// Index exists on { region: 1 } only — the query filters on region AND status:
db.orders.find({ region: "west", status: "cancelled" }).explain("executionStats").executionStats
// -> stage: "IXSCAN" (on region), totalKeysExamined: 40,000, nReturned: 60
// -> ratio: 40,000 / 60 ≈ 667 — the index narrowed by region, but examined every "west" order before filtering status in memory
1
The winning plan genuinely uses IXSCAN — this is not a case of falling back to COLLSCAN.
4
The scan-to-return ratio reveals the real story: the index only helps with region, and status filtering happens after fetching far more documents than the query actually needed.

Why this works: A stage-name check ("does it say IXSCAN") answers a different, easier question than a ratio check ("is this index actually earning its keep for this query") — both matter, but only the second one predicts whether a query is actually fast.

Declaring a query "optimized" the moment explain() shows IXSCAN instead of COLLSCAN

Wrong

text
// "It shows IXSCAN now instead of COLLSCAN, so the performance work here is done"

Better

text
// Check totalKeysExamined vs. nReturned specifically — a ratio far from 1 means the index is not well-aligned to this query, even though it is technically in use

What you see: A query "using an index" continues to perform poorly under real load, and the scan-to-return ratio (not checked) would have shown why.

Why: COLLSCAN-to-IXSCAN is a meaningful improvement, but it is not the finish line — a compound index matching the query's actual equality/sort/range fields (ESR, section 11) is what closes the gap between "uses an index" and "uses an index well," and that gap only shows up in the ratio, not the stage name.

IXSCAN is not the finish line

Query runs

region + status filter

IXSCAN on region

examines 40,000 keys

Check the ratio

totalKeysExamined ÷ nReturned

Ratio ≈ 667

misaligned, despite using an index

  • Query runs — region + status filter
    • leads to IXSCAN on region
  • IXSCAN on region — examines 40,000 keys
    • leads to Check the ratio
  • Check the ratio — totalKeysExamined ÷ nReturned
    • leads to Ratio ≈ 667
  • Ratio ≈ 667 — misaligned, despite using an index

Remember: IXSCAN in the plan only confirms an index was used, not that it fits the query well. Compare totalKeysExamined to nReturned — a ratio far from 1 means a poorly-aligned index despite technically "using" one.

See also: collscan vs ixscan · scan to return ratios

Advertisement