Indexing in MongoDB
Indexes are the single most important performance lever in MongoDB. Without them, every query scans the entire collection. This guide covers index types, creation strategies, and how to diagnose query performance with explain().
How Indexes Work
MongoDB uses B-tree indexes (WiredTiger storage engine). Each index entry maps an indexed field value to the document’s storage location.
Query without index: scan 1,000,000 docs → slow
Query with index: scan 50 index entries → fast
Trade-off: indexes speed reads but slow writes (each insert/update must update index entries).
Creating Indexes
// Single field, ascending
db.products.createIndex({ category: 1 })
// Descending (equivalent for single-field equality queries)
db.products.createIndex({ createdAt: -1 })
// Compound index — field order matters
db.orders.createIndex({ userId: 1, createdAt: -1 })
// Unique index
db.users.createIndex({ email: 1 }, { unique: true })
// Background build (default in 4.2+ — non-blocking)
db.logs.createIndex({ timestamp: -1 })
Index Types
Single-Field Index
db.products.createIndex({ sku: 1 })
// Supports: db.products.find({ sku: "WIDGET-01" })
Compound Index — ESR Rule
Order fields by Equality → Sort → Range:
// Query: find({ status: "active" }).sort({ createdAt: -1 }).limit(20)
db.orders.createIndex({ status: 1, createdAt: -1 })
// Query: find({ userId: X, createdAt: { $gte: date } })
db.orders.createIndex({ userId: 1, createdAt: -1 })
Compound index { a: 1, b: 1, c: 1 } supports queries on:
{ a }{ a, b }{ a, b, c }
But NOT { b } or { c } alone (left-prefix rule).
Multikey Index (Arrays)
db.articles.createIndex({ tags: 1 })
// Supports: db.articles.find({ tags: "mongodb" })
Each array element gets an index entry. Compound multikey indexes have restrictions — only one array field per compound index.
Text Index
db.articles.createIndex({ title: "text", body: "text" })
db.articles.find({ $text: { $search: "mongodb performance" } })
One text index per collection. Use Atlas Search for advanced full-text requirements.
Geospatial Index
db.stores.createIndex({ location: "2dsphere" })
db.stores.find({
location: {
$near: {
$geometry: { type: "Point", coordinates: [-122.33, 47.61] },
$maxDistance: 5000 // meters
}
}
})
Hashed Index (Sharding)
db.events.createIndex({ userId: "hashed" })
// Used as shard key for even distribution
Partial Index
Index only documents matching a filter — smaller, faster:
db.orders.createIndex(
{ userId: 1, createdAt: -1 },
{ partialFilterExpression: { status: "active" } }
)
// Only indexes active orders — ideal when most queries filter by status
TTL Index
Automatic document expiration:
db.sessions.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 3600 } // delete after 1 hour
)
Wildcard Index
Index all fields or a path subtree:
db.events.createIndex({ "metadata.$**": 1 })
// Supports queries on any field under metadata
Use sparingly — can grow large with dynamic schemas.
Explain Plans
const plan = db.orders.find({ userId: ObjectId("...") })
.sort({ createdAt: -1 })
.explain("executionStats")
// Key metrics:
// executionStats.executionStages.stage — "IXSCAN" (good) vs "COLLSCAN" (bad)
// executionStats.totalDocsExamined — should be close to nReturned
// executionStats.totalKeysExamined
// executionStats.executionTimeMillis
Reading explain Output
| Stage | Meaning |
|---|---|
COLLSCAN |
Full collection scan — missing or wrong index |
IXSCAN |
Index scan — good |
FETCH |
Retrieve documents after index lookup |
SORT |
In-memory sort — add sort fields to index |
Ideal plan: IXSCAN → FETCH with totalDocsExamined ≈ nReturned.
Index Management
// List indexes
db.orders.getIndexes()
// Drop unused index
db.orders.dropIndex("status_1_createdAt_-1")
// Hide index (test impact before dropping — Enterprise/Atlas)
db.orders.hideIndex("legacy_index_1")
// Index stats (usage since server start)
db.orders.aggregate([{ $indexStats: {} }])
Index Build in Production
- Index builds on populated collections consume CPU and I/O
- MongoDB 4.2+ builds indexes without blocking reads/writes (mostly)
- On large collections, build during low-traffic windows
- Monitor with
db.currentOp({ op: "command", "command.createIndexes": { $exists: true } })
Performance Tips
// Covered query — all fields in index, no FETCH needed
db.users.createIndex({ email: 1, name: 1, status: 1 })
db.users.find(
{ email: "[email protected]" },
{ name: 1, status: 1, _id: 0 }
)
// Hint to force specific index (debugging only)
db.orders.find({ userId: X }).hint({ userId: 1, createdAt: -1 })
Common Mistakes
- Creating indexes on every field — slows writes, wastes disk
- Wrong compound index field order — index exists but query still COLLSCAN
- Indexing low-cardinality fields alone (e.g., boolean
isActive) - Duplicate indexes:
{ a: 1, b: 1 }makes{ a: 1 }redundant - Not monitoring
$indexStats— unused indexes accumulate over years
Troubleshooting
Query uses wrong index
db.orders.find({ status: "active", userId: X }).explain("executionStats")
// If wrong index chosen, use hint() temporarily, then fix index design
Index build fails — duplicate key
// Unique index on field with duplicates
db.users.createIndex({ email: 1 }, { unique: true })
// Error: E11000 duplicate key — clean data first
db.users.aggregate([
{ $group: { _id: "$email", count: { $sum: 1 } } },
{ $match: { count: { $gt: 1 } } }
])
Index too large for RAM
- Use partial indexes to reduce size
- Archive old data to separate collections
- Increase WiredTiger cache (
cacheSizeGB)
Best Practices
- Design indexes from actual query patterns — not hypothetical ones
- Follow ESR rule for compound indexes
- Review
$indexStatsquarterly — drop unused indexes - Keep total index size under ~50% of RAM for hot data
- Use partial indexes for sparse query patterns
- Test with
explain("executionStats")before deploying new queries
Production Scenario: E-Commerce Orders
// Access patterns:
// 1. User order history: find({ userId }).sort({ createdAt: -1 })
// 2. Admin by status: find({ status: "pending" }).sort({ createdAt: 1 })
// 3. Order lookup by ID: findOne({ orderNumber: "ORD-12345" })
db.orders.createIndex({ userId: 1, createdAt: -1 })
db.orders.createIndex({ status: 1, createdAt: 1 })
db.orders.createIndex({ orderNumber: 1 }, { unique: true })
Three indexes for three distinct patterns — resist merging into one mega-index.
What Comes Next
The aggregation framework uses indexes via $match and $sort stages — place them early in pipelines for maximum benefit.