Filter concepts by levelShowing all levels.

MongoDB · Section 15

explain() and Performance Analysis

Level
advanced
Read
30 min
Concepts
5

The practical workflow built on section 14's explain() foundation: choosing among the three verbosity levels by the actual question being asked, reading nReturned/totalKeysExamined/totalDocsExamined/executionTimeMillis together to judge whether a plan is well-aligned, recognizing the "examine many, return few" pattern directly from a slow-query log line, confirming an index change with a real before/after comparison, and reaching for the database profiler — real production traffic and data volume — for diagnosis a local benchmark cannot reproduce.

This section

What is true here

  1. Match explain() verbosity to the actual question — allPlansExecution costs the most and is only needed for comparative questions.
  2. A ratio near 1 between nReturned and totalKeysExamined/totalDocsExamined means well-aligned; a large ratio means real, measurable waste.
  3. The "examine many, return few" pattern is visible directly in a slow-query log line, without running explain() first.
  4. Confirm an index change worked with a real before/after explain() comparison, not a subjective "feels faster" impression.
  5. The database profiler observes real production traffic and data volume — the evidence a local benchmark cannot reproduce.

What you will be able to do

  • Choose the right explain() verbosity level for a given investigation question
  • Judge whether a query plan is well-aligned from its four key executionStats numbers
  • Recognize an inefficient query directly from a slow-query log line, before running explain()
  • Confirm an index change actually improved a query with a captured before/after comparison
  • Enable and query the database profiler at a practical production threshold

Reading explain() output

Choosing the right verbosity, then the four numbers that actually judge whether a plan is well-aligned.

Choosing the right explain() verbosity

coreintermediate

explain("queryPlanner") — the default — shows the winning plan's shape with no execution. explain("executionStats") actually runs the query and adds real numbers for the winner. explain("allPlansExecution") adds those same real numbers for every candidate, useful specifically when the winner's choice itself is in question.

Think of it as

Pick verbosity by what question you're actually asking: "what plan would run" needs only queryPlanner; "how well did it actually perform" needs executionStats; "why did this plan win over that one" needs allPlansExecution. Reaching for the highest verbosity by default costs real execution time on every candidate, so matching the level to the question saves work as well as clarifying intent.

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

What we're doing: Match three real investigation questions to the correct verbosity level, showing the cost/benefit reasoning explicitly.

matching-question-to-verbosity.txttext
// Question: "will this new query use my new index at all?"
db.orders.find({ region: "west" }).explain("queryPlanner")   // cheapest — no need to actually run it

// Question: "this query feels slow — how much work is it really doing?"
db.orders.find({ status: "pending" }).explain("executionStats")   // real numbers, one execution

// Question: "I expected index A to win, but B did — why?"
db.orders.find({ status: "pending", region: "west" }).explain("allPlansExecution")  // compare both trials
2
A yes/no question about plan shape does not need real execution numbers — queryPlanner answers it for free.
5
A "why did X win over Y" question specifically needs the comparative data only allPlansExecution provides — the other two modes cannot answer it.

Why this works: Choosing verbosity by the actual question being asked, rather than defaulting to the highest level "to be safe," keeps performance investigation itself cheap and its output focused on what is actually needed.

Defaulting to allPlansExecution for routine index-shape checks

Wrong

text
// Using explain("allPlansExecution") as a habit for every explain() call, including simple "does this use an index" checks

Better

text
// Reserve allPlansExecution for genuinely comparative questions; use queryPlanner or executionStats for everything else

What you see: Routine query investigation on a large or slow collection takes noticeably longer than it needs to, because every check ran every candidate plan's full trial.

Why: allPlansExecution's cost is proportional to the number of candidate plans, all executed — most investigation questions ("does this use an index," "how much work did it do") are answered fully by a cheaper mode, and only genuinely comparative questions need the more expensive one.

Verbosity by cost and what it reveals
queryPlanner
cheapest — "what would run"
executionStats
runs the winner — real numbers
allPlansExecution
runs every candidate — "why did this win"
  • queryPlanner: no execution, plan shape only — cheapest — "what would run"
  • executionStats: no execution, real numbers — runs the winner — real numbers
  • allPlansExecution: executes every candidate, real numbers — runs every candidate — "why did this win"

Verbosity → what it costs → what it answers

Verbosity → what it costs → what it answers
ModeExecutes?Best for
queryPlannerno"what plan would run" — cheapest check
executionStatsyes, the winner"how much work did the real query do"
allPlansExecutionyes, every candidate"why did this plan win over that one"

Together

text
db.orders.find({ status: "pending" }).explain("queryPlanner")       // shape only
db.orders.find({ status: "pending" }).explain("executionStats")     // + real numbers for the winner
db.orders.find({ status: "pending" }).explain("allPlansExecution")  // + real numbers for every candidate

Remember: queryPlanner: plan shape, no execution. executionStats: runs the winner, real numbers. allPlansExecution: runs every candidate — reserve it for "why did this plan win" questions specifically, since it costs the most.

See also: explain basics · scan to return ratios

Reading executionStats: the key numbers

coreintermediate

Four numbers in explain("executionStats").executionStats tell the real story: nReturned (what the app got), totalKeysExamined (index entries checked), totalDocsExamined (documents checked), and executionTimeMillis (real time taken). The ratios of the first three to each other reveal how well-aligned the plan actually is.

Think of it as

nReturned is the goal; totalKeysExamined and totalDocsExamined are the cost paid to reach it. A well-aligned plan pays a cost close to the goal (ratio near 1); a poorly-aligned one pays far more than the goal requires. executionTimeMillis is the real-world consequence of that ratio, but the ratio itself is the diagnostic — it explains why the time is what it is.

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

What we're doing: Contrast a well-aligned query's numbers against a poorly-aligned one on the same collection, side by side.

well-vs-poorly-aligned.txttext
// Well-aligned: filter matches the leading field of a compound index
db.orders.find({ status: "pending", region: "west" }).explain("executionStats").executionStats
// { nReturned: 118, totalKeysExamined: 120, totalDocsExamined: 118 }  -> ratio ≈ 1.02, well-aligned

// Poorly-aligned: filter uses only the second field of that same compound index
db.orders.find({ region: "west" }).explain("executionStats").executionStats
// { nReturned: 4500, totalKeysExamined: 4500, totalDocsExamined: 4500 }  -> looks fine alone, but see the mistake below
1
A ratio near 1 across all three numbers is the signature of a well-aligned plan — nearly everything examined was actually needed.
5
This second query's ratio also looks close to 1 in isolation — the real problem here is comparative, covered in the mistake below.

Why this works: Reading these four numbers together, rather than any single one in isolation, is what actually diagnoses a plan — a ratio near 1 confirms the index is well-matched to this specific query's filter, not just that some index was used.

Judging a query's performance from executionTimeMillis alone, ignoring the scan-to-return ratio

Wrong

text
// "executionTimeMillis: 4 — that's fast, no problem here" — without checking totalKeysExamined vs. nReturned

Better

text
// Check the ratio regardless of how fast the query currently feels — a poor ratio on a small collection becomes a real problem as the collection grows

What you see: A query that "feels fast" on a small or lightly-loaded collection degrades sharply once the collection grows, because a poor scan-to-return ratio was always there, just not yet expensive enough to notice.

Why: executionTimeMillis reflects the current collection size and load, both of which change — the scan-to-return ratio reflects how well the plan itself is structured, which is what actually predicts how the query will behave as data grows, independent of today's absolute timing.

Cost paid vs. the goal

totalKeysExamined

index entries checked

totalDocsExamined

documents fetched

nReturned

the actual goal

  1. totalKeysExamined — index entries checked
  2. totalDocsExamined — documents fetched
  3. nReturned — the actual goal

Reading the four numbers together

Reading the four numbers together
NumbersWhat it means
totalKeysExamined ≈ nReturnedthe index narrowed almost exactly to the real matches
totalKeysExamined ≫ nReturnedthe index is not selective enough for this query — many false candidates checked
totalDocsExamined ≫ totalKeysExaminedmany index matches needed a full document fetch to be confirmed or used
totalDocsExamined = totalKeysExamined = nReturnedthe ideal case — often only possible with a covered query

Together

text
db.orders.find({ status: "pending" }).explain("executionStats").executionStats
// { nReturned: 118, totalKeysExamined: 120, totalDocsExamined: 118, executionTimeMillis: 4 }
// -> well-aligned: keys examined is barely above nReturned

Remember: nReturned is the goal; totalKeysExamined and totalDocsExamined are the cost paid to reach it. A ratio near 1 means well-aligned. executionTimeMillis varies run to run — trust the ratio over a single timing sample.

See also: explain verbosity modes · indexed but still slow

Advertisement

The practical workflow

Recognizing the inefficiency pattern in logs, confirming a fix with a real comparison, and diagnosing real production traffic with the profiler.

Recognizing the "examine many, return few" pattern

standardintermediate

This is the practical symptom to watch for across an application: a query with a small nReturned but a large totalDocsExamined or totalKeysExamined. It shows up in slow-query logs and the profiler, not just in one-off explain() calls, and is usually fixable with a better-aligned index.

Think of it as

Think of this pattern as the signature of a filter the index cannot fully narrow — the index gets the search close, but a large final filtering step still happens in memory or via extra document fetches. Recognizing the shape (small output, large intermediate work) from a log line, before even running explain(), is what lets someone triage many queries quickly rather than investigating each one from scratch.

text
// In a slow-query log or system.profile entry: compare docsExamined/keysExamined to nreturned directly

What we're doing: Recognize the pattern from a slow-query log line, then confirm it with explain() before deciding on a fix.

log-line-to-diagnosis.txttext
// A slow-query log line (or system.profile entry):
// { ns: "shop.orders", nreturned: 12, keysExamined: 85000, docsExamined: 85000, millis: 340 }

// The pattern is visible from the log alone: 85,000 examined for 12 returned — worth investigating
db.orders.find({ /* the logged filter */ }).explain("executionStats")
// -> confirm the exact stage tree and which field is (or isn't) narrowing the search
1
The ratio (85,000 examined / 12 returned) is visible directly in the log line, without running explain() first — this is what makes triage from logs practical.
5
explain() on the same filter confirms exactly where the misalignment is (missing index, wrong field order) once the log has flagged the query as worth investigating.

Why this works: Being able to recognize this pattern from a log line means performance triage does not require running explain() on every single query in the system — the ratio itself, visible in logged metadata, is the trigger for deciding which queries are worth a closer look.

Only investigating queries a developer happens to notice feel slow, rather than scanning logs for the examine-vs-return pattern systematically

Wrong

text
// Performance work driven entirely by ad-hoc reports of "this page feels slow," with no systematic review of slow-query logs

Better

text
// Periodically review the slow-query log or profiler for queries with a large examined-to-returned ratio, independent of whether anyone has complained yet

What you see: A query with a poor scan-to-return ratio runs in production for a long time before anyone notices, because it never becomes slow enough (or is run rarely enough) to prompt a complaint — but still adds unnecessary load the whole time.

Why: Ad-hoc, complaint-driven investigation only catches queries that are both frequent AND slow enough to be noticed subjectively — a systematic log/profiler review catches the same underlying pattern (large examine-to-return ratio) regardless of whether it has become noticeably slow yet.

Remember: A small nReturned against a large totalDocsExamined/totalKeysExamined is the "examine many, return few" pattern — recognizable from a slow-query log line directly, usually fixable with a better-aligned index.

See also: scan to return ratios · database profiler

Comparing plans before and after an index change

standardintermediate

The direct way to confirm an index change actually helped: run explain("executionStats") on the target query before creating the index, save the numbers, create the index, run it again, and compare. Anything less than an explicit before/after comparison is an assumption, not a verification.

Think of it as

An index change is a hypothesis ("this will make the query faster") until it is checked against real numbers — the same discipline as any other performance claim this project insists on, applied here to index design instead of a code example. The comparison is what turns "I added an index" into "I confirmed the query improved."

text
// Before: capture explain("executionStats"). Make the change. After: same query, same explain() call, compare.

What we're doing: Show a full before/after comparison, including the stage-tree change, not just a single number.

before-after-index-change.txttext
// Before: db.orders.createIndex({ status: 1 }) only
db.orders.find({ status: "pending" }).sort({ createdAt: -1 }).explain("executionStats")
// -> stage tree: SORT -> FETCH -> IXSCAN ; totalDocsExamined: 4,500 ; nReturned: 4,500 (all fetched, then sorted)

// After: db.orders.createIndex({ status: 1, createdAt: -1 })
db.orders.find({ status: "pending" }).sort({ createdAt: -1 }).explain("executionStats")
// -> stage tree: FETCH -> IXSCAN (no SORT) ; totalDocsExamined: 4,500 ; same nReturned, but no in-memory sort step
1
The "before" capture is the baseline — without it, there is nothing concrete to compare the change against.
5
The compound index eliminates the SORT stage entirely — a structural change in the stage tree, not just a smaller number, and the clearest possible confirmation the change worked as intended.

Why this works: Comparing the full stage tree, not just one number, catches improvements (or regressions) a single metric can miss — a SORT stage disappearing is a qualitatively different kind of confirmation than a timing number simply looking a bit lower.

Creating an index and assuming it helped without capturing a before state to compare against

Wrong

text
// db.orders.createIndex({ status: 1, createdAt: -1 })  — created, then just "seems faster now"

Better

text
// Capture explain("executionStats") before the change, so there is a real baseline to compare the after-state against

What you see: An index is added, the query subjectively "feels" faster, but there is no concrete evidence of what specifically changed or by how much — making it hard to know if the index is actually earning its ongoing write cost.

Why: A subjective "feels faster" impression cannot distinguish a real structural improvement (like a SORT stage disappearing) from noise (a lightly-loaded moment, a smaller batch of test data) — only a captured before/after comparison on the same query and data can.

Remember: Capture explain("executionStats") before an index change, make the change, run the identical query again, and compare the full stage tree and numbers — not just "it feels faster." A vanished SORT stage is stronger evidence than a smaller timing number alone.

See also: scan to return ratios · designing from real query patterns

The database profiler, for production-scale diagnosis

coreadvanced

db.setProfilingLevel(1, { slowms: 100 }) logs every operation slower than 100ms into the capped system.profile collection — real production traffic and data volume, not a local guess. Level 2 logs everything (expensive); level 0 (default) is off. explain() on one query is a hypothesis; the profiler on real traffic is the evidence.

Think of it as

A local benchmark tests one query against however much test data happens to exist locally — the profiler watches everything real users actually trigger, against real data volume and real concurrent load. The two answer different questions: "is this specific query well-designed" (explain(), local) versus "what is actually slow in production, and how often" (profiler, live traffic) — production diagnosis needs the second.

text
db.setProfilingLevel(1, { slowms: 100 })
db.system.profile.find({ millis: { $gt: 100 } }).sort({ ts: -1 })

What we're doing: Enable profiling at a practical production threshold, then query the results for the worst real offenders.

profiler-in-practice.txttext
db.setProfilingLevel(1, { slowms: 100 })   // log anything over 100ms

// later, investigate what's actually been slow in real production traffic:
db.system.profile.find({ millis: { $gt: 200 } })
  .sort({ ts: -1 })
  .limit(20)
1
Level 1 with a slowms threshold is a practical, ongoing setting — it only captures the operations actually worth investigating, not everything.
5
Querying system.profile directly surfaces real, recently-run slow operations — actual production filters, actual production data volume, not a local approximation of either.

Why this works: A local benchmark, however careful, cannot reproduce the concurrent load, real data distribution, and real query mix that only exists in production — the profiler observes reality directly instead of trying to simulate it, which is exactly why it is the tool for production diagnosis specifically.

Relying solely on local benchmark results to predict production query performance

Wrong

text
// "It performed fine against my local seed data" — no profiler data from actual production traffic ever checked

Better

text
// Enable the profiler (or an APM tool) in production and check system.profile / the tool's dashboard for how the query actually performs under real load and data volume

What you see: A query performs well in local testing and development, then turns out to be one of the slowest operations in production — a gap local benchmarking could not have caught.

Why: Local test data rarely matches production's real cardinality, skew, and volume, and local testing never reproduces production's concurrent load — the profiler (or an APM tool built on the same underlying data) is what closes that gap by observing the real thing instead of an approximation of it.

Diagnosing with real production evidence

Enable the profiler

setProfilingLevel(1, { slowms: 100 })

Real traffic runs

actual load, actual data volume, actual concurrency

Slow ops logged

into the capped system.profile collection

Query the evidence

system.profile.find({ millis: { $gt: N } })

  1. Enable the profiler — setProfilingLevel(1, { slowms: 100 })
  2. Real traffic runs — actual load, actual data volume, actual concurrency
  3. Slow ops logged — into the capped system.profile collection
  4. Query the evidence — system.profile.find({ millis: { $gt: N } })

Remember: The profiler (db.setProfilingLevel) watches real production traffic and data volume — level 1 with a slowms threshold for ongoing use, level 2 only for short targeted windows. explain() checks one hypothesis; the profiler is the evidence from real traffic.

See also: recognizing inefficient queries · explain basics

Advertisement