Filter concepts by levelShowing all levels.

MongoDB · Section 11

Compound Index Design

Level
advanced
Read
35 min
Concepts
7

A repeatable procedure (ESR: equality, sort, range) for ordering a compound index's fields, grounded in why field order is a genuinely different structure rather than a relabeling, the prefix rule that decides which query shapes an index actually serves efficiently, and the real read/write/memory trade-offs that decide when one compound index is the right call versus several separate ones.

What is true here

  1. ESR gives a repeatable procedure: classify each field Equality/Sort/Range for the target query, then order the index that way.
  2. Index design should start from real, captured query patterns — not the schema's field list or a guess.
  3. { a, b } and { b, a } are two different index structures, each efficient for a different set of queries.
  4. A prefix of a compound index is a leading field subset, in order — a non-leading field alone generally cannot use it.
  5. Every index costs storage and a per-write update, so compound-vs-separate is a real trade-off, not a free choice.

What you will be able to do

  • Apply ESR to classify a real query's fields and derive the correct compound index order
  • Explain why field order in a compound index is a real design decision, not a cosmetic one
  • Predict which query shapes a given compound index serves efficiently, using the prefix rule
  • Weigh the write/memory cost of an additional index against its read benefit before adding it
  • Decide when a collection needs a compound index, a separate index, or both for its real query shapes

The ESR procedure

A repeatable way to order a compound index's fields, grounded in real query patterns rather than guesswork.

ESR: equality, sort, range

coreintermediate

ESR is a naming convention for the ordering rule section 10 introduced: put Equality fields first, Sort fields second, Range fields last, when designing a compound index for a specific query. It is a repeatable procedure, not a one-off insight.

Think of it as

ESR turns "design a good compound index" from a judgment call into a checklist: read the query, label every field E (exact match), S (used in .sort()), or R (range/$in/$gt/etc.), then write the index in that label order. Two engineers applying ESR to the same query independently should converge on the same index.

text
// For a target query: db.createIndex({ <equality fields>: 1, <sort fields>: dir, <range fields>: 1 })

What we're doing: Apply ESR to a query where the fields appear in a different order than the index should use.

esr-applied-to-real-query.txttext
// Query as written, fields in this order: total (range), status (equality), createdAt (sort)
db.orders.find({ total: { $gte: 100 }, status: "shipped" }).sort({ createdAt: -1 })

// ESR classification: E = status, S = createdAt, R = total
// Index written in ESR order, NOT query-appearance order:
db.orders.createIndex({ status: 1, createdAt: -1, total: 1 })
1
The query happens to write total (a range field) before status (an equality field) — the order fields appear in the query has no bearing on index order.
5
ESR classification, not field-appearance order, decides the index: status (E) first, createdAt (S) second, total (R) last.

Why this works: ESR deliberately ignores the order a query happens to list its conditions in — the classification (what role each field plays) is what determines a good index, which is precisely why it produces the same answer regardless of how the query was typed.

Building the compound index in the same field order the query happens to be written in

Wrong

text
db.orders.createIndex({ total: 1, status: 1, createdAt: -1 })  // matches query-writing order, not ESR role

Better

text
db.orders.createIndex({ status: 1, createdAt: -1, total: 1 })  // matches each field's actual role: E, S, R

What you see: A compound index exists that "has all the right fields," but explain() still shows a SORT stage or a larger-than-expected totalDocsExamined.

Why: A query's field-writing order is an accident of how someone typed the filter — the field's actual role (equality, sort, or range) is what the index needs to reflect, and those two orderings frequently disagree.

A repeatable procedure, not a one-off judgment call

read the query

find + sort

label each field

E, S, or R

index in E-S-R order

regardless of query field order

  1. read the query — find + sort
  2. label each field — E, S, or R
  3. index in E-S-R order — regardless of query field order

Classifying a query's fields for ESR

Classifying a query's fields for ESR
Query clauseESR roleIndex position
{ status: "shipped" }Equality (E)1st
.sort({ createdAt: -1 })Sort (S)2nd
{ total: { $gte: 100 } }Range (R)3rd

Together

text
// Query: db.orders.find({ status: "shipped", total: { $gte: 100 } }).sort({ createdAt: -1 })
// E: status   S: createdAt   R: total
db.orders.createIndex({ status: 1, createdAt: -1, total: 1 })

Remember: ESR: classify each field as Equality, Sort, or Range for the target query, then build the compound index in that order — regardless of how the query happens to be written.

See also: key ordering and query shapes · designing from real query patterns

Designing indexes from real query patterns

standardintermediate

Start from the application's actual, measured queries — not from the fields that look "important," and not from every field a document happens to have. A field with no query filtering, sorting, or ranging on it has no reason to be in any index.

Think of it as

An index is a targeted tool built for a specific, known job — the job being one or more real queries the app runs. Designing "generically useful" indexes without a specific query in mind tends to produce indexes that look reasonable but do not actually match how the query planner needs to use them.

text
// Design order: capture the real query → classify its fields (ESR) → build the index → verify with explain()

What we're doing: Show an index designed from a query captured in the slow-query log, rather than guessed from the schema.

index-from-real-query.txttext
// Captured from the slow-query log — this is the query actually running in production:
db.orders.find({ customerId: 42, status: "pending" }).sort({ createdAt: -1 })

// Index designed to match it exactly, not a generic "index everything" index:
db.orders.createIndex({ customerId: 1, status: 1, createdAt: -1 })
2
This query came from the actual slow-query log, not a guess about what the app "probably" queries.
5
The index is built to exactly match this real query's equality fields and sort — nothing speculative added.

Why this works: A real captured query is verifiable evidence — you can check with explain() before and after that the index actually serves it — whereas an index designed from "this field seems important" has no such check until (or unless) the guessed query pattern actually occurs.

Designing indexes from the schema's field list instead of the application's actual queries

Wrong

text
// "This collection has 8 fields, let's index the 4 that look most queryable" — without checking what the app actually filters on

Better

text
// Pull real query shapes from the slow-query log or application logging, and design indexes to match those specifically

What you see: A collection accumulates several indexes that all look individually reasonable, but explain() on the app's actual slow queries still shows COLLSCAN or a poor scan-to-return ratio.

Why: Indexes designed from "what fields exist" rather than "what queries run" have no guarantee of matching the query planner's actual needs — the schema describes the data's shape, not the application's access pattern, and only the latter determines which index helps.

Remember: Design indexes from real, captured query shapes — filters, sorts — not from the schema's field list or a guess at what "seems important." Verify the result with explain().

See also: esr index reasoning · workload driven design

Advertisement

Order, prefixes, and cost

Why field order defines a different structure, which query shapes a prefix actually serves, and the real read/write trade-offs behind the compound-vs-separate decision.

Index field order changes what the index can do

standardbeginner

createIndex({ a: 1, b: 1 }) and createIndex({ b: 1, a: 1 }) are two different indexes, not the same index described two ways. They serve overlapping but different sets of queries efficiently — this is the concrete fact the prefix rule and ESR both build on.

Think of it as

Think of a compound index as a phone book sorted by one key, then the next — a book sorted "last name, then first name" is a genuinely different, differently-useful object from one sorted "first name, then last name," even though both contain the same names. Reordering the fields in createIndex() is building a different book, not relabeling the same one.

text
// createIndex({ a: 1, b: 1 }) and createIndex({ b: 1, a: 1 }) are two separate indexes to build and maintain

What we're doing: Show the same two fields, indexed in opposite orders, serving different queries as an efficient prefix.

order-changes-usable-prefix.txttext
db.orders.createIndex({ customerId: 1, status: 1 })
db.orders.find({ customerId: 42 })                    // efficient — customerId is the leading field
db.orders.find({ status: "pending" })                 // not efficient — status alone is not a prefix

// A second index, fields reversed:
db.orders.createIndex({ status: 1, customerId: 1 })
db.orders.find({ status: "pending" })                 // now efficient — status is the leading field here
1
With customerId leading, queries filtering by customerId (alone or with status) use this index well; status alone does not.
5
The reversed index is a genuinely different structure — it makes status-only queries efficient at the cost of an entirely separate index to maintain.

Why this works: This example is the concrete mechanism behind ESR and the prefix rule — neither is an arbitrary convention, both follow directly from the fact that a compound index's field order determines which field is the "outermost" sort key, and therefore which queries can use it as a prefix.

Treating { a: 1, b: 1 } and { b: 1, a: 1 } as interchangeable because they index "the same fields"

Wrong

text
// "We already have an index on customerId and status, so any query on either should be fast" — without checking which order it was created in

Better

text
// Check the actual field order of the existing index against the specific query's needs — order determines the usable prefix, field membership alone does not

What you see: A query filtering on the second field of an existing compound index still shows COLLSCAN or a large totalDocsExamined, despite "an index on that field existing."

Why: Two indexes over the same field set in different orders are not interchangeable — each is its own structure with its own usable prefix, and only the order that puts the query's equality/leading field first actually helps that query.

Remember: Field order in a compound index defines a genuinely different structure, not a relabeling — { a, b } and { b, a } serve different queries efficiently. Order is a real design decision, not a detail.

See also: prefix behavior · esr index reasoning

Compound index prefix behavior

coreintermediate

A prefix of createIndex({ a: 1, b: 1, c: 1 }) is any leading subset in order: { a }, { a, b }, or the full { a, b, c }. MongoDB can also use the index for { a, c } (skipping b), but less efficiently. It cannot efficiently use it for { b }, { c }, or { b, c } alone — none of those are a prefix.

Think of it as

Think of the index as a filing system nested by field, in order: files are grouped by a first, then by b within each a group, then by c within each b group. Asking "find everything where a = X" is a normal lookup in this system. Asking "find everything where c = Z" with no a or b constraint means checking inside every a-group and every b-group — the filing system's structure does not help at all.

text
// For createIndex({ a: 1, b: 1, c: 1 }), the efficient prefixes are: { a }, { a, b }, { a, b, c }

What we're doing: Show the specific efficiency cost of the "skip a middle field" case, since it works but is not free.

skip-middle-field-cost.txttext
db.inventory.createIndex({ item: 1, location: 1, stock: 1 })

// Skips "location" — still uses the index (item is the leading field), but less efficiently:
db.inventory.find({ item: "yeast", stock: { $gt: 50 } })
// -> scans every index entry for "yeast" across all locations, then filters by stock in that range
1
The index is built for item → location → stock, in that nested order.
4
Skipping location means MongoDB still starts from the item prefix, but then has to scan every location under that item rather than jumping directly to a stock range — a dedicated { item, stock } index would be more efficient for this exact query.

Why this works: The prefix rule is not a strict "works or doesn't" binary — MongoDB is deliberately lenient about using a leading prefix even when a middle field is skipped, but that leniency has a real performance cost that only shows up in explain()'s totalDocsExamined, not in whether the query "uses an index" at all.

Assuming any query naming a prefix field, in any position, is equally efficient

Wrong

text
// "item is in the index, so any query mentioning item is fast" — regardless of whether other fields are skipped

Better

text
// Check explain() specifically — a skipped middle field can mean a much larger totalDocsExamined than a query using every field in order

What you see: A query that "uses the index" per explain()'s stage name still examines far more documents than expected, because it skipped a middle field in the compound index.

Why: The prefix rule says whether an index can be used at all, not how efficiently — a query skipping a middle field passes the "can it use this index" test while still paying a real, measurable cost the stage name alone does not reveal.

Prefixes of { item, location, stock }

{ item }

a prefix

{ item, location }

a prefix

{ item, location, stock }

the full index — also a prefix

  1. { item } — a prefix
  2. { item, location } — a prefix
  3. { item, location, stock } — the full index — also a prefix

Which queries use { item: 1, location: 1, stock: 1 } efficiently

Which queries use { item: 1, location: 1, stock: 1 } efficiently
Query filters onUses this index?
itemyes — a prefix
item, locationyes — a prefix
item, location, stockyes — the full index
item, stock (location skipped)can, but less efficiently than a dedicated { item, stock } index
locationno — not a prefix
stockno — not a prefix
location, stockno — item, the leading field, is missing

Together

text
db.inventory.createIndex({ item: 1, location: 1, stock: 1 })

db.inventory.find({ item: "yeast" })                              // efficient prefix
db.inventory.find({ item: "yeast", location: "east-1" })          // efficient prefix
db.inventory.find({ location: "east-1", stock: { $gt: 0 } })      // NOT a prefix — item missing

Remember: A prefix of { a, b, c } is { a }, { a, b }, or { a, b, c } — leading subsets, in order. Non-prefix queries (a field alone that isn't leading) generally cannot use the index; skipping a middle field still works, but less efficiently.

See also: index order matters · compound index interaction

Indexes trade write and storage cost for read speed

standardintermediate

Every index is extra storage, and every write touching an indexed field also updates every index covering that field. A collection with ten indexes pays that cost ten times on a write that touches all ten fields — the read speedup is not free.

Think of it as

An index is a second, sorted copy of a field's values, kept in sync with the collection on every write. Fast reads come from not having to build that sorted view on the fly — but "kept in sync" means the database does the sorting-and-updating work at write time instead, on every single write, whether or not that write is ever followed by a read that needed the index.

text
// Every index on a collection adds to the cost of every write that touches its field(s) — weigh against actual read benefit

What we're doing: Contrast the write cost of one compound index versus several single-field indexes covering the same fields.

index-count-write-cost.txttext
// Five separate single-field indexes: an insert touching all five fields updates all five
db.orders.createIndex({ customerId: 1 })
db.orders.createIndex({ status: 1 })
db.orders.createIndex({ createdAt: 1 })
db.orders.createIndex({ total: 1 })
db.orders.createIndex({ region: 1 })
// -> one insertOne() call now does 6 index updates (5 + the default _id index)
2
Five indexes look individually harmless, but a single insert that populates all five fields pays for all five index updates.
6
The write cost is additive across every index that covers a field the write touches — this is the concrete mechanism behind "indexes are not free."

Why this works: The read/write trade-off is easy to state abstractly but easy to underweight in practice, because the read benefit (a fast query) is immediately visible while the write cost (slightly slower inserts, more memory pressure) accumulates quietly across every write, everywhere.

Adding a new index to fix a slow read without checking the collection's write volume

Wrong

text
// Adding a new index to a heavily-written, high-throughput collection purely because it speeds up one occasional report query

Better

text
// Weigh the read frequency and value of the query being optimized against the collection's actual write rate before adding an index

What you see: Overall write throughput on a hot collection drops after adding an index meant to help an infrequent query, and the collection's primary (write-heavy) workload is the one that actually suffers.

Why: An index's write cost is paid by every write to the collection, regardless of how often the read it was added for actually runs — an index justified by a rare report query can still measurably tax a collection whose dominant workload is writes.

Remember: Every index adds storage and a write-time update cost, paid on every write touching its field(s) — more indexes means more write cost, so index count is a trade-off against the collection's real write volume, not a free win.

See also: too many indexes cost · why indexes exist

How too many indexes hurt writes and memory

standardintermediate

Beyond the per-write update cost, indexes compete for the same memory (the working set) as the data itself — MongoDB performs best when its hot indexes and hot data both fit in RAM. Too many indexes, or indexes rarely used, push that memory budget past what fits, forcing more disk activity even for reads.

Think of it as

Think of RAM as a fixed-size desk, and the data plus every index as items competing for space on it. A well-chosen small set of indexes fits comfortably alongside the hot data. Adding more and more indexes — especially unused or rarely-used ones — is like piling more folders on the same desk: eventually something has to be swapped off to disk to make room, and reads that used to be fast now pay a disk-access cost that has nothing to do with the query itself.

text
// Periodically review index usage (section 59) — an index earns its write/memory cost only if something actually queries through it

What we're doing: Show write throughput and memory pressure both degrading as index count grows on the same collection, without any single index looking obviously wrong.

index-creep-symptom.txttext
// Over a year, feature after feature adds "just one more index" to the same collection:
// 2 indexes at launch -> 4 after feature A -> 7 after feature B -> 11 after feature C

// None added recklessly, but the collection now maintains 11 index updates per write,
// and its combined index size no longer fits comfortably in the deployment's available RAM
db.orders.insertOne({...})  // one write, eleven index updates
2
Each individual index addition seemed reasonable at the time it was added, for the feature that needed it.
5
The cumulative effect — eleven index updates per write, and a combined index size competing with hot data for RAM — is what actually causes the slowdown, not any single index in isolation.

Why this works: This is a gradual, cumulative effect rather than a single obvious mistake — which is exactly why it needs periodic review (checking real index usage, section 59) rather than being caught by inspecting any one index-creation decision on its own.

Never reviewing accumulated indexes, only ever adding new ones

Wrong

text
// A collection's index list only ever grows across the project's life — nothing is ever measured for actual usage or removed

Better

text
// Periodically check index usage (e.g. $indexStats) and drop indexes that are rarely or never used by real queries

What you see: Write throughput and memory pressure both degrade gradually over the life of a collection, without any single change that looks like the obvious cause.

Why: Index cost is cumulative and rarely reviewed by default — each addition is individually justified at the time, but nothing removes an index once the query pattern that justified it stops mattering, so the total cost only ever grows unless something actively prunes it.

Remember: Indexes compete with hot data for the same RAM working set — too many (or unused) indexes force disk access even for reads, on top of the per-write update cost. Review and prune index usage periodically, not just add.

See also: read write tradeoffs · separate vs single compound index

Separate indexes vs. one compound index

coreintermediate

A compound index wins when queries commonly filter on the same combination of fields together. Separate single-field indexes win when the fields are queried independently, in different combinations, or when writes only ever touch one of the fields at a time.

Think of it as

A compound index is a bet that certain fields are usually asked about together — it pays off exactly when that bet is right, and pays a needless write/storage cost when it is wrong (queries that only ever need one of the fields still maintain updates for all of them). Separate indexes make no such bet: each one only costs what its own field's writes require, at the cost of MongoDB needing to intersect results when a query does span more than one.

text
// Ask: are these fields usually queried together, or independently? The answer decides compound vs. separate.

What we're doing: Show a case where a compound index alone is insufficient, because one of its fields is also queried on its own frequently.

compound-plus-separate.txttext
// Common: filtering by customer AND status together
db.orders.createIndex({ customerId: 1, status: 1 })

// Also common, independently: support agents looking up a single order by tracking number alone
db.orders.createIndex({ trackingNumber: 1 })
// -> two indexes, each serving a genuinely different, real query pattern — neither replaces the other
2
This compound index efficiently serves "this customer's orders, optionally filtered by status" — the common storefront query.
6
trackingNumber is not a prefix of the compound index, so a separate index is the only way to make that independent lookup efficient — the compound index would not help it even though trackingNumber technically exists as a field on the same documents.

Why this works: A collection commonly has more than one real, independent query shape — recognizing that a single compound index cannot efficiently serve every shape is what leads to the right mix of one compound index plus targeted separate indexes, rather than forcing everything into one.

Folding every frequently-queried field into one large compound index, assuming bigger is more efficient

Wrong

text
db.orders.createIndex({ customerId: 1, status: 1, trackingNumber: 1, region: 1 })  // one wide index, hoping it covers everything

Better

text
// Split into indexes matching actual query shapes: { customerId, status } for storefront queries, { trackingNumber } for independent lookups

What you see: Queries that only filter on trackingNumber still show a poor scan ratio, because trackingNumber is buried as a non-leading field in the wide compound index rather than being its own index or the leading field of one.

Why: A wide compound index only efficiently serves queries matching its own prefix — folding in a field that is also queried independently, but not as part of that prefix, does not make independent queries on that field any faster, it just makes the shared index bigger and costlier to maintain.

Which fits this query shape?

One compound index

  • +Fields queried together, same combination
  • +{ customerId: 1, status: 1 }
  • +Serves the storefront query efficiently

Separate indexes

  • Fields queried independently
  • { trackingNumber: 1 } on its own
  • Not a prefix of the compound index
  • One compound index
    • Fields queried together, same combination
    • { customerId: 1, status: 1 }
    • Serves the storefront query efficiently
  • Separate indexes
    • Fields queried independently
    • { trackingNumber: 1 } on its own
    • Not a prefix of the compound index

Signal → which to choose

Signal → which to choose
SignalChoose
Queries always filter on the same 2–3 fields togetherone compound index over those fields
Fields are queried independently, in different combinationsseparate single-field indexes
One field is updated far more often than the othersconsider a separate index for it, not folded into a shared compound index
A field is queried both alone and combined with anothera compound index with that field leading often covers both, per the prefix rule

Together

text
// Queries always filter on customerId + status together: one compound index
db.orders.createIndex({ customerId: 1, status: 1 })

// A separately, independently queried field: its own index
db.orders.createIndex({ trackingNumber: 1 })

Remember: Compound index: fields commonly queried together. Separate index: a field queried independently, in varying combinations, or updated on its own. A collection often needs both, for different real query shapes.

See also: designing from real query patterns · too many indexes cost

Advertisement