MongoDB Query & Index Optimization
1. The ESR Rule
Primary compound index design framework: Equality → Sort → Range. Place predicate types in this order to minimize index bounds and avoid blocking operations.
Predicate Types
- Equality: Exact match, tight index bounds
[value, value](e.g.,status: "active") - Sort:
.sort()fields, unbounded bounds[MinKey, MaxKey](e.g.,createdAt: -1) - Range:
$gte/$lt/$ne, loose bounds[5, inf](e.g.,age: { $gte: 18 })
Sub-Rule: E Before S
Equality before sort avoids blocking in-memory SORT stage.
BAD EXAMPLE
Sort field before equality: forces in-memory sort
Bad Indexdb.users.createIndex({ createdAt: -1, status: 1 })
// totalKeysExamined: 1000, SORT stage present
GOOD EXAMPLE
Equality before sort: index supports sort order
Good Indexdb.users.createIndex({ status: 1, createdAt: -1 })
// totalKeysExamined: 120, no SORT stage
Sub-Rule: E Before R
Equality narrows key space before range scanning begins.
BAD EXAMPLE
Range before equality: scans all range values first
Bad Indexdb.users.createIndex({ age: 1, status: 1 })
// totalKeysExamined: 500
GOOD EXAMPLE
Equality first: narrows to 120 matching status before range scan
Good Indexdb.users.createIndex({ status: 1, age: 1 })
// totalKeysExamined: 120
Sub-Rule: S Before R
Range before sort forces blocking sort even with an index. After a range scan, index order is not guaranteed to match sort requirements.
BAD EXAMPLE
Bad Indexdb.orders.createIndex({ status: 1, total: 1, createdAt: -1 })
// Query: status="shipped", total>100, sort createdAt:-1
// Explain: SORT stage present
GOOD EXAMPLE
Good Indexdb.orders.createIndex({ status: 1, createdAt: -1, total: 1 })
// Query: status="shipped", total>100, sort createdAt:-1
// Explain: IXSCAN only, no SORT stage
Multiple Equality Fields
Order among equality fields does not matter. B-Tree leaf pages store all combinations equally, and selectivity among E fields is irrelevant to key count.
2. Index Types
Single Field Index
Index on a single document field. Use for frequent single-field filters/sorts. Not suitable for compound queries.
Syntaxdb.collection.createIndex({ fieldName: 1 }) // 1=ascending, -1=descending
Compound Index
Index on multiple fields, follows ESR rule. Use for queries with multiple filter/sort/range fields.
Syntaxdb.collection.createIndex({ equality1: 1, equality2: 1, sort1: -1, range1: 1 })
Multikey Index
Auto-created by MongoDB when indexing array fields. Only one array field per compound index allowed. Performance cost: index size grows with array cardinality.
Syntaxdb.collection.createIndex({ tags: 1 }) // tags is an array field
Text Index
Supports text search with scoring. One text index per collection, case-insensitive. Use hint() to force usage.
db.collection.createIndex({ content: "text" })
db.collection.find({ $text: { $search: "mongodb" } }).hint({ content: "text" })
Hashed Index
Good for hash-based sharding, equality only. Cannot support range queries.
Syntaxdb.collection.createIndex({ _id: "hashed" })
Wildcard Index
Use for dynamic/unpredictable field names. Not suitable for high-cardinality known fields.
Syntaxdb.collection.createIndex({ "$**": 1 })
Partial Index
Only indexes documents matching a filter expression. Saves storage and write overhead. Example: index only active users.
Syntaxdb.users.createIndex({ email: 1 }, { partialFilterExpression: { status: "active" } })
Sparse Index
Only indexes documents where the field exists. Differs from partial index: partial uses custom filter, sparse only checks field existence.
Syntaxdb.users.createIndex({ phoneNumber: 1 }, { sparse: true })
TTL Index
Auto-deletes documents after expiry. Only on Date fields, single field only. Background deletion runs every 60 seconds.
Syntaxdb.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 })
Geospatial Indexes
2dsphere for GeoJSON, 2d for legacy coordinates. Use with $near, $geoWithin, $geoIntersects.
db.places.createIndex({ location: "2dsphere" })
3. Index Selectivity
Selectivity = ratio of matching documents to total documents. High selectivity (fewer matches) = better index efficiency.
- Low-selectivity indexes (boolean, status enums) can be worse than COLLSCAN
- Measure selectivity:
db.collection.countDocuments(query) / db.collection.countDocuments() - Composite index selectivity is more important than individual field selectivity
- Most selective equality field does NOT need to be first among E fields (ESR rule only requires E before S/R)
4. Covered Queries
Query satisfied entirely by index, no document fetch. Requirements: all queried fields AND projected fields must be in the index.
BAD EXAMPLE
Missing projection field in index, causes FETCH stage
Querydb.users.find({ status: "active" }, { name: 1, email: 1, _id: 0 })
// Index: { status: 1, name: 1 }
// Explain: FETCH stage present
GOOD EXAMPLE
All projected fields in index, eliminates FETCH stage
Good Indexdb.users.createIndex({ status: 1, name: 1, email: 1 })
// Explain: IXSCAN only, no FETCH stage
Note: _id must be explicitly excluded if not in index. Only optimize for covered queries if the query is high-frequency.
5. Query Planner and Explain()
Query planner selects candidate plans, caches winning plan. Invalidate plan cache with index changes or collection modifications.
Explain Modes
queryPlanner: Returns plan selection without executionexecutionStats: Returns execution metrics for winning planallPlansExecution: Returns metrics for all candidate plans
Key Explain Fields
stage: COLLSCAN (bad), IXSCAN (good), FETCH, SORT (blocking), PROJECTIONtotalKeysExamined: Number of index keys scannedtotalDocsExamined: Number of documents fetchedexecutionTimeMillis: Query execution timeindexBounds: Tight (equality) vs loose (range) boundsnReturned / totalKeysExamined: Efficiency ratio (>0.8 is good)
Use hint() to force index usage: db.collection.find(query).hint({ indexName: 1 })
6. Aggregation Pipeline Optimization
- Place
$matchand$sortas early as possible $matchbefore$lookupto reduce documents in join$projectearly to reduce document size through pipeline$limitbefore$sortfor top-N patterns$groupcan use indexes only if$matchprecedes it with indexed fields$lookupoptimization: index foreign collection's join field, use$matchinside$lookuppipeline$unwindon multikey fields has high performance cost- Pipeline stage order matters: MongoDB can only push
$match/$sortpast certain stages - Use
{ allowDiskUse: true }for large pipelines that exceed memory limits
7. Write Performance vs Index Count
- Every index slows insert, update, delete operations
- Do not create indexes speculatively. Build indexes after bulk inserts
- Drop unused indexes with
$indexStats:db.collection.aggregate([{ $indexStats: {} }])(observe for at least one full query cycle before dropping) - Redundant index example:
{a:1, b:1}makes{a:1}redundant
8. Common Query Anti-Patterns
- Leading wildcard regex:
{ name: /^.*son/ }can't use index; anchored/^John/can - $where/$function: JavaScript execution, always COLLSCAN
- Negation operators:
$ne/$nin/$notscan all non-matching documents - Large $in arrays: Performance degrades with >1000 values
- Array queries without multikey awareness: Unexpected index size growth
- Deep pagination with skip(): Use keyset pagination (range-based on _id/timestamp) instead
- No projections: Fetching full documents when only 2 fields needed
- Type coercion mismatches: Querying
age: "18"when field is Number misses index
9. Schema Design Impact on Query Performance
- Embedding avoids expensive
$lookupfor related data - Avoid deeply nested documents for frequently queried fields (dotted path queries work but add complexity)
- Large array fields in multikey indexes blow up index size
- Avoid storing query-critical data only inside large subdocument arrays
10. Working Set and Memory
Working set = most frequently accessed data and indexes. Should fit in WiredTiger cache (RAM).
- Estimate working set size: sum of frequently accessed documents + indexes
- Monitor WiredTiger cache with
db.serverStatus().wiredTiger.cache - Large indexes that exceed RAM cause increased disk I/O
11. Quick-Reference Index Design Checklist
- ☐ Have you classified every field as E, S, or R?
- ☐ Are equality fields first?
- ☐ Are sort fields before range fields?
- ☐ Is the index selective enough to be worth it?
- ☐ Will this create a covered query opportunity?
- ☐ Does an existing index already cover this query (redundancy check)?
- ☐ What write overhead does this add?
- ☐ Have you run explain() in executionStats mode to verify?
12. Common Mistakes Quick Reference
| Mistake | Why it hurts | Fix |
|---|---|---|
| Leading wildcard regex | Can't use index, full scan | Use anchored regex or text index |
| $where/$function | JS execution, always COLLSCAN | Rewrite to native operators |
| Negation operators | Scans all non-matching docs | Restructure query to use equality |
| Deep pagination with skip() | O(n) scan for large offsets | Use keyset pagination |
| No projections | Fetches unnecessary data | Always project required fields |
| Type mismatch in query | Misses index hits | Match field type in query |
| Speculative indexes | Slows writes unnecessarily | Only create indexes for proven queries |
| Redundant indexes | Wastes storage and write overhead | Drop indexes covered by compound indexes |
14. Read Preferences and Replica Set Routing
Read preferences control which replica set member serves read operations. Five modes:
primary: Only primary (default, strongest consistency)primaryPreferred: Primary if available, else secondarysecondary: Only secondariessecondaryPreferred: Secondary if available, else primarynearest: Lowest-latency node (any member)
When to Use Secondary Reads
- Reporting, analytics, batch jobs that tolerate stale data
- Read-heavy workloads where primary is saturated
Stale Read Risk
Secondary replication lag means data may not be current. Never use secondary reads for read-your-own-writes — use causal consistency sessions instead.
Causal Consistency Sessions
Guarantees monotonic reads across replica set members. Sessions track the last write operation and wait for secondaries to catch up.
Node.js Exampleconst session = client.startSession({ causalConsistency: true });
await db.collection.insertOne({ x: 1 }, { session });
const result = await db.collection.findOne({ x: 1 }, { session });
Nearest Mode
Routes to lowest-latency node, good for globally distributed deployments but carries staleness risk from replication lag.
Member Tagging
Route specific query types to dedicated nodes. Example: tag a secondary for analytics workloads.
Python Exampleclient = MongoClient( "mongodb://host1,host2,host3/?readPreference=secondary&readPreferenceTags=workload:analytics" )
15. Connection Pooling and Driver Configuration
Connection pooling reuses TCP connections to avoid expensive per-query handshake overhead. Default pool size is 100 across most drivers.
Key Pool Settings
maxPoolSize: Set based on concurrency needs and mongod connection limits. Too low = queued operations; too high = server overload.minPoolSize: Keeps warm connections alive to avoid cold start latency spikes.connectTimeoutMS: Timeout for establishing a connection. Too low = failures during network blips; too high = hung operations.socketTimeoutMS: Timeout for socket read/write. Too low = premature timeouts on slow queries; too high = hung sockets.maxConnecting: Limits simultaneous connection establishment to avoid bursts.waitQueueTimeoutMS: How long an operation waits for a pool connection before failing. Set low to surface pool exhaustion quickly.
Pool Exhaustion Symptoms
Queued operations, latency spikes, "connection pool timed out" errors.
Rule: Singleton MongoClient
Never create a new MongoClient per request — reuses the same pool across the application process.
BAD EXAMPLE
New client per request handler
Node.js (Bad)app.get("/users", async (req, res) => {
const client = new MongoClient(uri); // BAD: new client per request
await client.connect();
const users = await client.db().users.find().toArray();
res.json(users);
});
GOOD EXAMPLE
Module-level singleton client
Node.js (Good)// mongo-client.js
const client = new MongoClient(uri);
module.exports = client;
// handler.js
const client = require("./mongo-client");
app.get("/users", async (req, res) => {
const users = await client.db().users.find().toArray();
res.json(users);
});
Python (Good)
# mongo_client.py
client = MongoClient(uri)
# handler.py
from mongo_client import client
def get_users():
return list(client.db.users.find())
16. WiredTiger Storage Engine Internals
WiredTiger is MongoDB's default storage engine, using B-Tree pages for document and index storage.
WiredTiger Cache
Defaults to 50% of RAM minus 1GB. This is your working set limit — data/indexes outside the cache incur disk I/O.
Check Cache Sizedb.serverStatus().wiredTiger.cache["maximum bytes configured"]
Checkpoints
Dirty data flushed to disk every 60 seconds or when the journal fills. Checkpoint spikes can cause temporary latency.
Compression
Default is snappy (fast, moderate compression). Alternatives: zlib (higher compression, more CPU), zstd (best balance, MongoDB 4.2+).
Concurrency Tickets
Read/write tickets control concurrent transactions (default 128 each). Sustained values below 128 indicate server saturation. MongoDB 7.0+ uses dynamic ticket adjustment.
Document-Level Locking
WiredTiger locks at document level, not collection level — enables high concurrent write throughput for distinct documents.
Cache Eviction
When cache fills, eviction threads remove cold data. Monitor eviction rate to detect working set exceeding cache size.
Key Metricsdb.serverStatus().wiredTiger.cache["bytes currently in the cache"] db.serverStatus().wiredTiger.cache["pages read into cache"] db.serverStatus().wiredTiger.concurrentTransactions.read.available db.serverStatus().wiredTiger.concurrentTransactions.write.available
17. Monitoring and the Database Profiler
Self-Hosted Profiling
Profiler logs slow or all operations to the system.profile collection.
db.setProfilingLevel(0) // Off
db.setProfilingLevel(1, { slowms: 100 }) // Slow ops >100ms
db.setProfilingLevel(2) // All ops (NEVER in production)
Reading system.profile
Key fields: op (operation type), ns (namespace), millis (duration), keysExamined, docsExamined, planSummary.
db.system.profile.find({ millis: { $gt: 100 } }).sort({ ts: -1 }).limit(10)
Profiler Overhead
Level 2 writes to system.profile for every op — only use temporarily on a secondary.
Persistent Slow Query Logging
Set slowOpThresholdMs in mongod.conf instead of relying on Level 1 profiling for production.
MongoDB Atlas Tools
- Performance Advisor: Automated slow query detection + index suggestions ranked by wasted bytes. Check weekly.
- Query Profiler: Per-query breakdown, execution time, keys/docs examined ratio, index coverage flag.
- Real-Time Performance Panel (RTPP): Live opcounters, active operations, scan/returned ratio.
- Namespace Insights: Collection-level latency trends over time.
Key Metrics to Track
docsExaminedReturnedRatio: Should be close to 1.0 for well-indexed queries.keysExaminedReturnedRatio: High ratio = unselective index.numYields: High yields signal resource contention or long-running ops.responseLength: Large responses indicate missing projections.
Find Zero-Ops Indexes
Aggregationdb.collection.aggregate([{ $indexStats: {} }])
// Look for indexes with "accesses.ops": 0 after a full query cycle
18. Transactions and Multi-Document Operations
Multi-document transactions provide ACID guarantees across multiple documents/collections — but add significant performance cost.
Performance Cost
- Transactions hold locks longer than single-document operations.
- WiredTiger write conflicts increase under high concurrency.
- Each operation inside a transaction adds round-trip latency.
Best Practices
- Keep transactions as short as possible — do all prep work outside the transaction, only writes inside.
- Avoid reads inside transactions unless necessary — snapshot reads can cause write conflicts.
- Prefer single-document atomicity (embedding related data) over transactions across collections.
- Use retryable writes for simple idempotent operations instead of wrapping in a transaction.
transactionLifetimeLimitSeconds defaults to 60s — transactions exceeding this are aborted automatically.
Sharded Cluster Transactions
Distributed transactions add coordinator overhead — even more reason to keep them small.
BAD EXAMPLE
Reading, transforming, and writing 50 documents in one transaction
Transaction (Bad)const session = client.startSession();
session.startTransaction();
const docs = await db.orders.find({ status: "pending" }, { session }).toArray();
const updated = docs.map(d => ({ ...d, status: "processed" }));
await db.orders.insertMany(updated, { session });
await session.commitTransaction();
GOOD EXAMPLE
Compute outside the transaction, write only final result inside
Transaction (Good)const docs = await db.orders.find({ status: "pending" }).toArray();
const updated = docs.map(d => ({ ...d, status: "processed" }));
const session = client.startSession();
session.startTransaction();
await db.orders.insertMany(updated, { session });
await session.commitTransaction();
19. Pagination Patterns
Why skip() Is Dangerous at Scale
MongoDB must scan and discard (page × pageSize) documents before returning results. Page 1000 with pageSize 20 scans 20,000 docs.
BAD EXAMPLE
skip() for page 1000, pageSize 20
Query (Bad)db.users.find().sort({ _id: 1 }).skip(1000 * 20).limit(20)
// explain(): totalDocsExamined = 20000
Keyset Pagination (Cursor-Based)
Use the last seen value of an indexed field as the anchor for the next page instead of skip(). Works best with unique monotonic fields (_id, timestamp + _id compound).
GOOD EXAMPLE
Query (Good)db.users.find({ _id: { $gt: lastSeenId } }).sort({ _id: 1 }).limit(20)
// explain(): totalDocsExamined = 20
Limitation: Cannot jump to arbitrary pages — only forward/backward navigation.
$facet for Paginated Counts
Combine $count and $skip/$limit in a single pipeline pass for total count + paginated results. Use when you need total page count, but avoid for high-traffic endpoints due to $facet memory overhead.
Aggregationdb.users.aggregate([
{ $facet: {
total: [{ $count: "count" }],
results: [{ $sort: { _id: 1 } }, { $skip: 20 }, { $limit: 20 }]
} }
])
Infinite Scroll vs Traditional Pagination
Keyset pagination is natural for infinite scroll. Traditional offset pagination needs special handling to avoid duplicate documents when new data is inserted during navigation.
20. Caching Strategies with MongoDB
Add a cache layer for expensive, read-heavy queries returning data that doesn't change on every request.
Redis Cache Patterns
Cache-Aside (Most Common)
App checks Redis first, falls back to MongoDB, writes result to Redis with TTL.
Node.js Exampleasync function getUserProfile(userId) {
const cached = await redis.get(`user:${userId}`);
if (cached) return JSON.parse(cached);
const user = await db.users.findOne({ _id: userId });
await redis.set(`user:${userId}`, JSON.stringify(user), "EX", 3600);
return user;
}
Write-Through
Write to both MongoDB and Redis on update to guarantee consistency.
What to Cache (and Not)
- Good candidates: User profile lookups, config/settings documents, aggregate counts that update infrequently.
- Bad candidates: Data requiring strong consistency, high write frequency data, documents with unpredictable TTL.
Cache Invalidation
- TTL-based: Simple, risks stale data for TTL duration.
- Event-driven: Use MongoDB Change Streams to invalidate cache on document change in real time.
MongoDB Internal Caches
- Plan cache: Stores winning query plans (not results) — distinct from application-level caching.
- Cursor batching: Drivers batch results to reduce network round trips. Tune
batchSizefor large result sets.
21. Index Build Performance
MongoDB 4.4+ Index Builds
Hybrid approach: brief exclusive lock at start/end, concurrent reads/writes in between. Safe for production on large collections.
Before 4.4: Foreground builds blocked all operations — never run in production.
Rolling Index Builds (Replica Sets)
Build index on each secondary one at a time while primary stays live. Step down primary, build on it last. Avoids replication lag spikes for large collections.
Index Builds on Sharded Clusters
Each shard builds independently. Use rolling approach per shard for large collections.
Monitor Index Build Progress
Check Running Buildsdb.currentOp({ "command.createIndexes": { $exists: true } })
Hide Index Before Dropping
Hidden indexes are not used by the query planner but still maintained. Validate nothing breaks before permanent drop — safer than immediate drop.
Syntaxdb.collection.hideIndex("indexName")
// Monitor for issues, then:
db.collection.dropIndex("indexName")
Schedule During Off-Peak Hours
Index builds consume I/O and CPU — schedule on low-traffic windows. On Atlas: use rolling index build option in the UI.
22. Unbound Arrays and Large Document Anti-Patterns
Unbound Arrays
Arrays with no upper size limit that grow over time. Risks:
- Every array update rewrites the entire array into the document.
- Multikey index on an unbound array grows with each element added.
- 16MB document size limit becomes a real constraint.
Detection: Monitor avg document size with db.collection.stats().avgObjSize.
Bucket Pattern
Group time-series or event data into fixed-size bucket documents instead of one document per event.
BAD SCHEMA
One document per event (unbound array of events per user)
Bad Schema{
userId: "user123",
events: [
{ type: "click", ts: ISODate("2026-01-01") },
// ... grows unbound
]
}
GOOD SCHEMA
Fixed-size bucket documents (100 events per bucket)
Good Schema{
userId: "user123",
bucketStart: ISODate("2026-01-01"),
events: [ /* up to 100 events */ ],
count: 100
}
// Query: db.events.find({ userId: "user123", bucketStart: { $gt: ... } })
Outlier Pattern
Handle rare documents with massive arrays separately to avoid degrading performance for typical cases.
Large $in Arrays
Query planner treats each value as a separate scan. Over ~100-200 values, performance degrades significantly — break into batches or restructure the query.
Document Size Monitoring
Spot CheckObject.bsonsize(db.collection.findOne()) // Single doc size db.collection.stats().avgObjSize // Avg collection doc size
23. Time-Series Collections
Specialized collection type optimized for append-mostly, time-ordered data (IoT sensors, metrics, logs, financial tick data).
Internal Structure
MongoDB automatically clusters documents into compressed bucket documents (not visible to the app) aligned with time-range access patterns.
Required Fields
timeField: Date field for time ordering (automatically indexed).metaField(optional): Field for grouping related time-series data (e.g., sensorId).
db.createCollection("metrics", {
timeseries: {
timeField: "timestamp",
metaField: "sensorId",
granularity: "hours"
}
})
Query Performance
Range queries on timeField are extremely fast. Add secondary indexes on metaField for filtered time-range queries.
Windowed Aggregations
Use $setWindowFeatures for moving averages, running totals. Use $densify to fill gaps in sparse time series.
db.metrics.aggregate([
{ $setWindowFields: {
partitionBy: "$sensorId",
sortBy: { timestamp: 1 },
output: { movingAvg: { $avg: "$value", window: { range: [-5, 0] } } }
} }
])
TTL on Time-Series
Set expireAfterSeconds at collection creation to auto-expire old buckets — more efficient than TTL on regular collections.
Limitations
- No multi-document transactions.
- No arbitrary updates (only inserts and deletes by time range).
- MongoDB 8.0+: Better downsampling, smarter bucket sizing.
24. Query Shapes and Plan Cache
A query shape is the structure of a query without its literal values — same shape = same cached plan.
How the Plan Cache Works
- MongoDB evaluates candidate plans in a trial period.
- Winning plan stored in the plan cache by query shape.
- Cached plans are reused without re-evaluation until invalidated.
Plan Cache Invalidation Triggers
- Index added or dropped.
- Collection rebuilt.
- mongod restart.
- Server parameter changes.
Manage Plan Cache
View Cached Plansdb.collection.getPlanCache().list()Clear Plan Cache (Use with Caution)
db.collection.getPlanCache().clear()
Stale Cached Plans
Data distribution changes significantly but cache isn't invalidated — old plan may be suboptimal. Use explain("allPlansExecution") to manually re-evaluate.
$planCacheStats Aggregation
Collection-level view of all cached plans, their hit counts, and whether they are active.
Aggregationdb.collection.aggregate([{ $planCacheStats: {} }])
hint() as Last Resort
Force a specific index when the planner consistently picks the wrong plan. Always document why the hint is needed.
Syntaxdb.collection.find(query).hint({ indexName: 1 })
Interactive Tools
ESR Classifier Tool
Enter your query fields to get the recommended ESR index order.
Recommended IndexExplain Plan Reader
Enter execution stats to diagnose query performance.
Shard Key Evaluator
Input field details to score shard key suitability.
Pagination Cost Calculator
Compare docs scanned for skip() vs keyset pagination.
Transaction Complexity Checker
Check if a transaction is appropriate for your use case.