Lesson 0004 · The planner

The query planner & explain()

You can read EXPLAIN in your sleep. Mongo's explain() looks familiar — a tree of stages — but the planner underneath doesn't cost plans from statistics the way Postgres does. It races them and keeps the observed winner.

~11 minWarm-up + retrieval quiz + EXPLAIN contrastCheat sheet: here

Warm-up — apply lesson 0003 first (cold, from memory)

For find({ status: "active", age: {$gt:21} }).sort({ name: 1 }), the best index is:

ESR: status is equality (first), name is the sort (second, so the index returns rows pre-sorted), age is the range (last). Equality-Sort-Range → { status, name, age }.

Index { a: 1, b: 1, c: 1 }. Which query can it serve as a prefix?

Only a leading prefix works: a, or a+b, or a+b+c. Anything that skips the leading field a can't use the index — the InnoDB leftmost-prefix rule.

A multikey (array) index and a covered query:

A multikey index holds one entry per array element, so it can't reconstruct the whole array from the index — MongoDB must fetch the document. Multikey indexes never cover.

Here is a skill that transfers almost whole. In Postgres 0006 and MySQL 0003 you learned to read a plan as a tree of stages, bottom-up, and to hunt the one number that matters most. Mongo's explain() is the same tree in different clothes. But one thing underneath is genuinely different, and it's the keystone of this lesson: Postgres and MySQL estimate a plan's cost from table statistics and pick the cheapest before running it. MongoDB's classic planner instead runs the candidate plans against each other and keeps whichever actually wins.

Three verbosities

explain() takes a verbosity. queryPlanner (the default) shows the plan the optimizer would use, without running it. executionStats actually runs the query and reports what happened. allPlansExecution adds the trial-run data for the losing plans too. MongoDB Manual: to include executionStats "you must run the explain in either: executionStats or allPlansExecution verbosity mode"; allPlansExecution includes "partial execution data captured during plan selection." For real tuning you almost always want executionStats — the plan alone can lie; the measured run cannot. (Unlike Postgres, this is not a DML hazard: an explain of a write does not apply it.)

Reading the tree: stages

The plan lives in winningPlan, with the losers in rejectedPlans. MongoDB Manual: winningPlan = "the plan selected by the query optimizer"; rejectedPlans = "candidate plans considered and rejected by the query optimizer." Each node is a stage, and the two you'll read most are the two you already know by other names:

STAGE MEANS YOUR SQL WORD COLLSCAN read every document Seq Scan / full table scan IXSCAN walk index keys Index Scan FETCH go get the document a key points to the heap/table lookup SORT sort in memory (no index order to use) an explicit Sort node ──────────────────────────────────────────────────────────────────────────── Ideal shape: IXSCAN → (FETCH) A covered query is IXSCAN with NO FETCH. Bad shape: COLLSCAN A SORT stage = the index didn't serve the sort.

MongoDB Manual: stages include COLLSCAN "collection scan," IXSCAN "scanning index keys," FETCH "retrieving documents." Notice how 0003 pays off: an IXSCAN with no FETCH is exactly a covered query; a lone SORT stage means your index didn't supply the order and MongoDB paid for an in-memory sort — the thing an ESR index exists to remove.

The keystone: how the winner is chosen (a race, not an estimate)

This is where MongoDB parts ways with everything in your SQL courses. When the shape is new, the optimizer builds the candidate plans and runs them side by side for a short trial period; the winner is the plan that produces the most results while doing the least work during that trial. MongoDB Manual: "In the classic multi-planner, the winning plan is the query plan that produces the most results during the trial period while performing the least amount of work." (MongoDB 8.3+ adds a cost-based ranker as a backup when the trial can't decide. MongoDB Manual: "Starting in MongoDB 8.3, multi-planning with a cost-based ranker backup is the default plan selection mechanism… CBR evaluates each node in a plan based on a cost function and its cardinality estimations.")

The contrast to burn in

Postgres's planner never runs your query to choose — it multiplies row estimates by per-operation costs and picks the cheapest on paper (which is exactly why stale statistics were the villain in Postgres 0008). MongoDB's classic planner sidesteps estimation error by empirically racing the plans — but it pays a different tax: the winner is cached by query shape and reused, so a plan chosen on an unrepresentative trial can stick around.

The #1 skill: examined vs returned

Just as Postgres's key tell was estimated vs actual rows, MongoDB's is keys/documents examined vs documents returned. In executionStats three numbers matter: nReturned (docs returned), totalKeysExamined (index entries walked), and totalDocsExamined (documents opened). MongoDB Manual: nReturned "Number of documents returned"; totalKeysExamined "Number of index entries scanned"; totalDocsExamined "Number of documents examined… not… the number of documents returned."

The health check is a ratio: examined ≈ returned (near 1:1) is efficient; a big gap means you're reading piles of data to emit a few rows. MongoDB Manual: when "keys examined match the number of documents returned… a very efficient query"; an unindexed query shows totalKeysExamined: 0 with totalDocsExamined = whole collection.

nReturned: 3 totalKeysExamined: 3 totalDocsExamined: 3 ← 1:1, indexed, ideal nReturned: 3 totalKeysExamined: 0 totalDocsExamined: 10 ← COLLSCAN: read all 10 for 3 nReturned: 3 totalKeysExamined: 1000 totalDocsExamined: 1000 ← index too broad / wrong ESR order

The plan cache

The winning plan is stored in the query plan cache, keyed by plan cache query shape, and reused for later queries of the same shape. MongoDB Manual: "Both ranking mechanisms store the chosen plan in the query plan cache and reuse it for subsequent queries with the same plan cache query shape." It clears itself when the ground shifts: any DDL event — creating, dropping, or hiding an index — flushes the cache for that collection, and it also drops entries by LRU and does not survive a restart. MongoDB Manual: "Any DDL event clears the plan cache for the relevant collection… dropping a collection, and creating, deleting, or hiding an index." Also LRU eviction, and "does not persist if a mongod restarts." Two practical corollaries: after you add an index, the next query re-plans (good — it can discover your new index); and explain() deliberately ignores the cache and won't create an entry, so it always shows a fresh planning decision. MongoDB Manual: "Using explain ignores all existing plan cache entries and prevents the MongoDB query planner from creating a new plan cache entry."

explain(): SQL vs MongoDB

QuestionPostgres / MySQL EXPLAINMongoDB explain()
Choose a plan by…Estimating cost from statisticsRacing candidate plans (classic)
#1 tellEstimated vs actual rowsExamined vs returned
See real numbers with…EXPLAIN ANALYZEexplain("executionStats")
Running a write planANALYZE executes it (danger)Safe — explain doesn't apply it
Full scan is calledSeq Scan / full scanCOLLSCAN
Reused plan cachePrepared-statement generic planCache keyed by query shape

Check yourself

Answer from memory — the effortful recall is what builds retention. Feedback is immediate.

How does MongoDB's classic planner pick the winning plan?

The classic multi-planner races candidate plans for a trial period and keeps the one that returns the most results for the least work — an empirical run-off, not a cost estimate from stats like Postgres.

Which single comparison best tells you a query is efficient?

Examined-to-returned near 1:1 means little wasted work — the Mongo cousin of Postgres's estimated-vs-actual-rows tell. A big gap means reading lots to emit little.

In a plan, an IXSCAN with no FETCH stage means:

No FETCH means MongoDB answered from index keys alone — a covered query (from 0003). A FETCH means it went to the document; a SORT stage means the index didn't supply the order.

You create a new index. What happens to that collection's plan cache?

Any DDL event — including creating, dropping, or hiding an index — clears the collection's plan cache, so the next query re-plans and can discover the new index.

Which verbosity actually runs the query and reports real counts?

queryPlanner (default) shows the intended plan without running it; executionStats runs the query and reports nReturned and the examined counts. Reach for executionStats when tuning.
Optional — when you have an instance

No Mongo handy yet, so this is a lab for later (Docker: docker run --rm -p 27017:27017 mongo, then mongosh):

db.people.find({ status: "active", age: { $gt: 21 } })
         .sort({ name: 1 }).explain("executionStats")   // read stage + the 3 counts
// Before any index: expect COLLSCAN, totalKeysExamined 0, totalDocsExamined = N.
db.people.createIndex({ status: 1, name: 1, age: 1 })    // the ESR index
// Re-run explain: expect IXSCAN, no SORT stage, examined ≈ nReturned.
db.people.getPlanCache().list()                          // see cached shapes + states

The win to watch: the SORT stage disappears and totalDocsExamined collapses toward nReturned. Bring the two plans (before/after) to your teacher and we'll read them side by side.

Primary source — read this next

MongoDB Manual — Analyze Query Performance is the practical one-pager (COLLSCAN vs IXSCAN, the examined:returned ratio). Then Explain Results for every field, and Query Plans & the Plan Cache for how the winner is chosen and cached.