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.
Learning objectives
Separate candidate enumeration from normal cached execution.
Inspect winning and rejected plans without assuming every query exposes multiple candidates.
Understand Missing, Inactive, and Active plan-cache states conceptually.
Use planCacheShapeHash versus
planCacheKey correctly.
Recognize MongoDB 8.3 cost-based-ranker (CBR) fields as conditional/version-dependent evidence.
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.
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.
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.
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});
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.
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");
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.
docker rm -f atlasmart-mongo-ch12-l2docker volume rm atlasmart-mongo-ch12-l2-data
Check your understanding
- Why can explain disagree with a cached application execution?
-
What changes
planCacheKeybut not necessarilyplanCacheShapeHash? - Is a rejected plan “bad forever”?
- Does every ordinary query create a cache entry?
- 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.