A single-field index answers a single-field question. Real queries filter on several fields and sort on another, and the order in which you list those fields in a compound index decides whether the query is served from the index in one pass or degrades into a scan plus an in-memory sort. After this lesson you will be able to design compound indexes with the Equality-Sort-Range rule, know which queries an index can serve through its prefixes, and understand how MongoDB indexes array fields.
{ customerId: 1, status: 1, createdAt: -1 } stores one entry per document, sorted first by customerId, then by status within each customer, then by createdAt (descending) within each status. Because entries are physically ordered this way, the index can only be entered from the left:
| Query fields | Uses the index? |
|---|---|
| { customerId } | yes, prefix |
| { customerId, status } | yes, prefix |
| { customerId, status, createdAt } | yes, full key |
| { customerId, createdAt } | partially: seeks on customerId, filters createdAt inside the scanned range |
| { status } or { createdAt } | no, not a prefix |
This is the prefix rule. It also means { customerId: 1 } is redundant once { customerId: 1, status: 1 } exists.
When a query combines exact matches, a sort and a range, order the index fields as:
status: "open", customerId: 42). Each equality narrows the scan to one contiguous block of keys.SORT stage and without the 100 MB in-memory limit.total: { $gt: 100 }, $in with many values, $ne). A range splits the block, so anything after it can no longer be used for ordering.db.orders.createIndex({ customerId: 1, status: 1, createdAt: -1, total: 1 })
const stats = db.orders
.find({ customerId: 42, status: "open", total: { $gt: 100 } })
.sort({ createdAt: -1 })
.explain("executionStats").executionStats;
stats.totalKeysExamined; // close to nReturned
stats.totalDocsExamined; // equals nReturned: only matching documents were fetchedPut the range field before the sort field and the plan changes to an IXSCAN followed by a blocking SORT. Both plans use the index, but only the ESR order lets the index do the sorting.
A single-field index serves both sort directions because the server can walk it backwards. For compound sorts the pattern must match the index or its exact inverse:
db.orders.createIndex({ status: 1, createdAt: -1 })
db.orders.find().sort({ status: 1, createdAt: -1 }) // index order
db.orders.find().sort({ status: -1, createdAt: 1 }) // inverse: still fine
db.orders.find().sort({ status: 1, createdAt: 1 }) // mixed: in-memory SORTIndex a field that holds arrays and MongoDB automatically creates a multikey index with one key per element, so { tags: "office" } is an index lookup rather than a scan:
db.products.createIndex({ tags: 1 })
db.products.find({ tags: "office" }).explain().queryPlanner.winningPlan
// ... "isMultiKey": true ...Rules that follow from one-key-per-element:
Design one index per distinct query shape, then let the prefix rule collapse overlaps. Verify what exists and what is actually used:
db.orders.getIndexes()
db.orders.aggregate([{ $indexStats: {} }]) // per-index usage counters since restart
db.orders.dropIndex("customerId_1") // remove a redundant prefix indexEvery index costs write throughput and RAM; the working set of indexes should fit in memory. An index that $indexStats shows with accesses.ops: 0 for weeks is a candidate for removal.
Which index best serves `find({ status: "open", qty: { $lt: 5 } }).sort({ updatedAt: -1 })`?
getIndexes() and $indexStats, and drop indexes that are prefixes of others.Next lesson: Unique, Partial, TTL and Wildcard Indexes — enforce uniqueness, index only the documents that matter, expire data automatically and index unpredictable fields.