Trace candidate-plan selection and cache state without confusing explain output with normal cached execution.

Winning vs Rejected Plans, Candidate Enumeration, and Plan Cache Behavior

Understand candidate selection and plan-cache state without assuming explain reproduces cached execution or that yesterday's winner is permanent.

Intermediate110–150 minutesCandidates + plan cache + CBR labMongoDB 8.3.8 · mongosh 2.10.0Last reviewed: September 2026

Learning objectives

01

Separate candidate enumeration from normal cached execution.

02

Inspect winning and rejected plans without assuming every query exposes multiple candidates.

03

Understand Missing, Inactive, and Active plan-cache states conceptually.

04

Use planCacheShapeHash versus planCacheKey correctly.

05

Recognize MongoDB 8.3 cost-based-ranker (CBR) fields as conditional/version-dependent evidence.

Reproducible lab baseline

This lesson pins MongoDB Community Server 8.3.8 with mongodb/mongodb-community-server:8.3.8-ubuntu2204-slim and mongosh 2.10.0. The server is a disposable standalone published only on loopback 127.0.0.1:27073. Authentication and TLS are disabled only for this isolated local lab. Feature Compatibility Version (FCV) is observed but never changed. Default read/write concern and primary read preference apply. Atlas, Search, KMS, and Enterprise Advanced are not mandatory. Plan-cache contents are process/member-local and ephemeral. MongoDB 8.3 changed $planCacheStats output and may use the cost-based ranker as a backup for eligible queries. Runtime measurements are not pre-filled: this generation environment has no Docker/mongod/mongosh runtime, so learners must record the values produced on their own machine.

1. AtlasMart problem: two plausible indexes, one query shape

A candidate plan is a physical strategy the planner considers for a query. A winning plan is selected for execution; rejected plans are alternatives not chosen. A plan cache query shape groups queries by predicate/sort/projection structure rather than literal values. Its planCacheShapeHash depends on shape, while planCacheKey also depends on indexes currently available for that shape.

MongoDB 8.3 can use the classic multi-planner and, for eligible queries, a cost-based-ranker backup. If CBR participates, explain may include abstract costEstimate, cardinalityEstimate, and estimation metadata. Those fields are not guaranteed for every query and should never be hard-coded into monitoring parsers.

bash · isolated Chapter 12 Lesson 2 lab setup
docker rm -f atlasmart-mongo-ch12-l2 2>/dev/null || truedocker volume rm atlasmart-mongo-ch12-l2-data 2>/dev/null || truedocker run -d --name atlasmart-mongo-ch12-l2 \  -p 127.0.0.1:27073:27017 \  -v atlasmart-mongo-ch12-l2-data:/data/db \  mongodb/mongodb-community-server:8.3.8-ubuntu2204-slimmongosh "mongodb://127.0.0.1:27073/atlasmart?directConnection=true" --quiet --eval \'printjson({server:db.version(),hello:db.hello().isWritablePrimary}); printjson(db.getSiblingDB("admin").runCommand({getParameter:1,featureCompatibilityVersion:1}))' 

2. Seed data and create genuinely competing indexes

The two compound indexes contain the same equality fields in opposite order, followed by the same sort field. Both are plausible for the target query, which lets the planner have something real to compare.

javascript · fixture plus two viable compound indexes
const c=db.orders_ch12_l2;c.drop();const base=new Date("2026-08-01T00:00:00Z");let b=[];for (let i=0;i<30000;i++) {  b.push({_id:i,tenantId:`tenant-${i%30}`,status:["paid","packed","shipped"][i%3],createdAt:new Date(base.getTime()+i*30000),totalCents:500+(i%12000)});  if (b.length===1000) { c.insertMany(b); b=[]; }}if (b.length) c.insertMany(b);c.createIndex({tenantId:1,status:1,createdAt:-1},{name:"idx_tenant_status_created"});c.createIndex({status:1,tenantId:1,createdAt:-1},{name:"idx_status_tenant_created"});printjson({count:c.countDocuments({}),indexes:c.getIndexes().map(x=>x.name)});

3. Inspect candidate selection with allPlansExecution

allPlansExecution runs the winning plan to completion and includes partial trial-period statistics for alternate candidates when they exist. It is diagnostic, not a normal request path. Because explain bypasses the cache, this section tells you what candidates were evaluated now—not what a prior application call reused from cache.

javascript · winning, rejected, and trial-period evidence
const c=db.orders_ch12_l2;const filter={tenantId:"tenant-7",status:"shipped"};const sort={createdAt:-1};const ex=c.find(filter).sort(sort).limit(100).explain("allPlansExecution");printjson({  shape:ex.queryPlanner?.planCacheShapeHash,  key:ex.queryPlanner?.planCacheKey,  winning:ex.queryPlanner?.winningPlan,  rejected:ex.queryPlanner?.rejectedPlans,  allPlansExecution:ex.executionStats?.allPlansExecution});
8.3 boundary

If your output includes CBR cost/cardinality estimates, interpret them as planner estimates, not measured latency. If it does not, that does not indicate failure; only a subset of eligible queries invokes CBR. Explain output also varies between classic and slot-based execution engines.

4. Observe the cache with normal executions

Plan-cache state is created by ordinary eligible query execution, not by explain. The cache can move through Missing → Inactive → Active states; exact transition timing is implementation/runtime-dependent. Not every query gets a cache entry: the optimizer only caches shapes that can have more than one viable plan. PlanCache.list() wraps $planCacheStats.

javascript · populate, inspect, then invalidate plan cache
const c=db.orders_ch12_l2;const pc=c.getPlanCache();pc.clear();const q={tenantId:"tenant-7",status:"shipped"};for (let i=0;i<10;i++) c.find(q).sort({createdAt:-1}).limit(100).toArray();printjson(pc.list([{$project:{version:1,isActive:1,planCacheShapeHash:1,planCacheKey:1,timeOfCreation:1,peakTrackedMemBytes:1,createdFromQuery:1}}]));// DDL invalidates the relevant collection plan cache.c.createIndex({totalCents:1},{name:"idx_disposable_total"});print("after createIndex cache entries:",pc.list().length);c.dropIndex("idx_disposable_total");
DDL is cache-invalidating evidence

Creating, dropping, or hiding an index clears the relevant collection's plan cache. A server restart also clears it. Therefore “the plan was cached yesterday” is not a durable production guarantee.

5. Deliberately wrong: force a hint because yesterday's plan was bad

A hard-coded hint() can turn a diagnosis into a long-lived constraint that outlasts the data distribution it was meant to fix. Use hints as controlled experiments: compare normal planner behavior with a candidate index while holding dataset/query constant. If a persistent policy is genuinely required, MongoDB 8.0+ query settings are the supported cluster-persistent mechanism; deprecated index filters should not be the default answer.

Production judgment. Cache inspection is a snapshot, not a contract. On replica sets different members can have different cache contents because they serve different read traffic. On sharded clusters $planCacheStats targeting depends on read preference/allHosts behavior. Observe shape hashes, candidate work, actual execution stats, and workload distributions before intervening.

bash · cleanup / full reset
docker rm -f atlasmart-mongo-ch12-l2docker volume rm atlasmart-mongo-ch12-l2-data

Check your understanding

  1. Why can explain disagree with a cached application execution?
  2. What changes planCacheKey but not necessarily planCacheShapeHash?
  3. Is a rejected plan “bad forever”?
  4. Does every ordinary query create a cache entry?
  5. What is safer than using a permanent application hint as the first fix?
Review the answers

1. Explain intentionally ignores existing plan-cache entries and does not populate the cache.

2. Adding or removing usable indexes can change the key because it includes available-index information, while the shape hash represents the query structure.

3. No. It was rejected for the current planning event, data/index state, and planner behavior.

4. No. Shapes with only one viable plan may not be cached, and cache behavior is implementation-dependent.

5. Measure normal behavior, use hints only for controlled comparison, and use supported query settings only when an explicit policy is justified.

Authoritative references

  • MongoDB 8.3 release notes — Current stable series, patch-sensitive behavior, 8.3 query-planning/profiling additions.
  • Explain command — Verbosity modes and explain behavior.
  • Explain results — Plan stages, execution statistics, query-shape hashes, and version-dependent output.
  • Query plans — Candidate selection, cost-based ranker backup, plan-cache states, and cache invalidation.
  • PlanCache.list() — Current mongosh plan-cache inspection interface.
  • $planCacheStats — Plan-cache documents and engine-dependent output.
  • Database profiler — Profiler levels, overhead, filters, thresholds, and security considerations.
  • $currentOp — Preferred live-operation inspection stage.
  • Slow query monitoring — Profiler/currentOp diagnostic workflow.
  • $queryStats — Managed-deployment query-shape statistics and stability/availability caveats.
  • Query shapes — MongoDB 8.x query-shape and plan-cache-shape terminology.
  • Query settings — MongoDB 8.0+ persistent query-shape settings that supersede deprecated index filters.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.