Database NotesRohit’s interview study guide
Chapter 20

MongoDB Indexes

How MongoDB finds documents without reading all of them: index types, reading explain output, the ESR rule, and keeping indexes healthy.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Why indexes matter

Without an index, the only way MongoDB can answer find({ email: "..." }) is a collection scan (COLLSCAN): read every document, check the condition, keep the matches. That's fine for 100 documents and ruinous for 100 million. An index is a separate, sorted structure (a B-tree, like InnoDB's) holding the values of one or more fields, each with a pointer to the document. Because it's sorted, the server can jump straight to "user77777@example.com" in a few steps instead of reading everything.

The mental model is the index at the back of a textbook. To find "replication" you don't read all 600 pages; you look it up alphabetically and go straight to pages 212 and 340. But the index isn't free: it takes pages of its own, and every time the author adds a paragraph about replication, someone has to update the index too. In MongoDB, every index makes writes slower and uses disk and RAM, so you create the ones your queries need and no others.

Every collection starts with exactly one index, the unique index on _id, which can't be dropped. The examples in this chapter use a users collection of 100,000 generated documents (name, unique email, gender, dob: { date, age }, city, a hobbies array, plan, score and signup date) in the ch19 database, so the explain numbers are real. A lookup by email, first with only the _id index, then after indexing email:

mongosh
db.users.find({ email: "user77777@example.com" }, { _id: 0, name: 1, city: 1 })
const s1 = db.users.find({ email: "user77777@example.com" }).explain("executionStats").executionStats
({ stage: s1.executionStages.stage, nReturned: s1.nReturned, totalKeysExamined: s1.totalKeysExamined,
   totalDocsExamined: s1.totalDocsExamined, executionTimeMillis: s1.executionTimeMillis })
db.users.createIndex({ email: 1 })
const s2 = db.users.find({ email: "user77777@example.com" }).explain("executionStats").executionStats
({ stage: s2.executionStages.stage, inputStage: s2.executionStages.inputStage.stage, nReturned: s2.nReturned,
   totalKeysExamined: s2.totalKeysExamined, totalDocsExamined: s2.totalDocsExamined,
   executionTimeMillis: s2.executionTimeMillis })
Result
[ { name: 'Chloe Khan', city: 'Mumbai' } ]
{
  stage: 'COLLSCAN',
  nReturned: 1,
  totalKeysExamined: 0,
  totalDocsExamined: 100000,
  executionTimeMillis: 11
}
email_1
{
  stage: 'FETCH',
  inputStage: 'IXSCAN',
  nReturned: 1,
  totalKeysExamined: 1,
  totalDocsExamined: 1,
  executionTimeMillis: 1
}
What’s happening
  1. The query finds one user. The interesting part is how much work it took, which explain("executionStats") reports; the two ({ ... }) lines just pick the important fields out of its long output.
  2. Without an index on email, the plan is COLLSCAN: 100,000 documents examined to return 1. It took 11 ms here only because the whole collection is cached in RAM; on disk-bound data it would be far slower.
  3. createIndex({ email: 1 }) builds an ascending index on email and returns its generated name, email_1.
  4. Now the plan is IXSCAN (walk the index to the one matching key) feeding FETCH (load that one document): 1 key and 1 document examined.
  5. That ratio, documents examined vs documents returned, is the number to look at for every query. 100,000 : 1 is a missing index; 1 : 1 is perfect.
AdvancedTiming 200 lookups

A single lookup is too quick to time by hand on cached data, so here are 200 lookups by email from mongosh, without and with the index (lookups() runs findOne for 200 different emails and returns the elapsed milliseconds):

mongosh
const lookups = (n) => { const t = Date.now(); for (let i = 1; i <= n; i++) db.users.findOne({ email: "user" + (i * 397 % 100000 + 1) + "@example.com" }); return Date.now() - t }
db.users.dropIndex("email_1").ok
({ withoutIndexMs: lookups(200) })
db.users.createIndex({ email: 1 })
({ withIndexMs: lookups(200) })
Result
1
{ withoutIndexMs: 793 }
email_1
{ withIndexMs: 69 }
What’s happening
  1. Without the index, each lookup scans 100,000 documents: about 4 ms each, 793 ms for 200.
  2. With the index, 200 lookups take 69 ms, about 0.35 ms each, and most of that is the round trip between mongosh and the server, not the search.
  3. A second run gave 827 ms and 72 ms. The gap grows with the collection: the scan is O(n), the index lookup O(log n). At 100 million users, the scan would take seconds per query and the index lookup would still be under a millisecond.
AdvancedWhat indexes cost: write time and space

Here 100,000 user documents are inserted into an empty collection twice, first with only the _id index, then with four more indexes. load() drops and recreates the collection, creates the given indexes, inserts in batches of 1,000, forces a checkpoint so the sizes are up to date, and reports timings and sizes:

mongosh
load([])
load([ { email: 1 }, { city: 1, score: -1 }, { hobbies: 1 }, { "dob.age": 1 } ])
Result
{ indexes: 1, insertMs: 727, dataMB: 24.7, perIndexMB: { _id_: 2.6 } }
{
  indexes: 5,
  insertMs: 1092,
  dataMB: 24.7,
  perIndexMB: {
    _id_: 2.6,
    email_1: 4.1,
    'city_1_score_-1': 2.6,
    hobbies_1: 5.7,
    'dob.age_1': 2.2
  }
}
What’s happening
  1. With only _id, the inserts take 727 ms. With four extra indexes they take 1,092 ms: 50% slower, because every insert now also adds an entry to five B-trees. A second run gave 761 ms vs 1,197 ms.
  2. dataMB is the uncompressed size of the documents (24.7 MB). The index sizes are on-disk sizes, which WiredTiger shrinks with prefix compression (sorted keys that share a beginning are stored once).
  3. hobbies_1 is the largest because it's multikey: each user has 1 to 4 hobbies, so the index holds about 250,000 keys for 100,000 documents (see Multikey indexes).
  4. Together the four indexes take 14.6 MB, more than half the size of the data itself. Indexes need to fit in RAM to be fast, so every index you add competes for the cache with your documents.
AdvancedWhen an index isn't worth it
  • Write-heavy collections with rare reads (logs, events): each index slows every insert. Index only what's queried.
  • Low-selectivity fields on their own (a boolean, a status with three values): an index that matches 40% of the collection is often slower than a scan; see the selectivity example in explain() and executionStats.
  • Tiny collections: a scan of 50 documents is as fast as an index lookup.
  • Duplicate coverage: an index on { a: 1 } is redundant if { a: 1, b: 1 } exists, because the compound index serves a-only queries too.

#explain() and executionStats

explain() shows how MongoDB ran (or would run) a query: which plan the query planner chose, which indexes it considered, and, at the executionStats level, how much work each step did. It's the single most important tool for query performance, the equivalent of EXPLAIN / EXPLAIN ANALYZE in MySQL (see Reading EXPLAIN).

Think of it as asking a delivery driver not just "did the parcel arrive?" but "which route did you take, how many streets did you drive down, and how many doors did you knock on?". The three numbers to read every time are nReturned (documents returned), totalKeysExamined (index entries read) and totalDocsExamined (documents loaded). In an ideal query all three are about equal. My notes recorded an example where totalDocsExamined was 5,000 while only 1,222 rows were returned: 3,778 documents were loaded and thrown away, and that waste is what you tune away.

There are three verbosity levels: "queryPlanner" (the default) shows the chosen plan without running the query; "executionStats" runs it and adds counters; "allPlansExecution" also shows the counters for the plans that lost. Here's the full executionStats for a query with no useful index:

mongosh
db.users.find({ "dob.age": { $gt: 60 }, gender: "female" }).explain("executionStats").executionStats
Result
{
  executionSuccess: true,
  nReturned: 15919,
  executionTimeMillis: 17,
  executionTimeMicros: 17695,
  totalKeysExamined: 0,
  totalDocsExamined: 100000,
  executionStages: {
    isCached: false,
    stage: 'COLLSCAN',
    filter: {
      '$and': [ { gender: { '$eq': 'female' } }, { 'dob.age': { '$gt': 60 } } ]
    },
    nReturned: 15919,
    executionTimeMillisEstimate: 10,
    works: 100001,
    advanced: 15919,
    needTime: 84081,
    needYield: 0,
    saveState: 0,
    restoreState: 0,
    isEOF: 1,
    nss: 'ch19.users',
    direction: 'forward',
    docsExamined: 100000,
    seeks: 0
  }
}
What’s happening
  1. nReturned: 15919: about 16% of users are women over 60.
  2. totalKeysExamined: 0 and totalDocsExamined: 100000: no index was used, every document was read. The stage confirms it: COLLSCAN, with both conditions applied as a filter to each document.
  3. works: 100001 is the number of work units the stage did (one per document plus one to detect the end). advanced: 15919 is how many it passed up as results, and needTime: 84081 is how many it examined without producing a result: the waste.
  4. executionTimeMillis: 17 is the whole query's time on the server, excluding network. isEOF: 1 means the scan reached the end.
  5. Read it top-down as "what was returned" and bottom-up as "what actually happened": executionStages is a tree, and each stage's inputStage feeds it.
AdvancedWith an index: the 5,000 vs 1,222 situation from my notes

Add an index on dob.age only and the plan changes, but not all the waste goes away:

mongosh
db.users.createIndex({ "dob.age": 1 })
const ex = db.users.find({ "dob.age": { $gt: 60 }, gender: "female" }).explain("executionStats")
ex.queryPlanner.winningPlan
const e = ex.executionStats
({ nReturned: e.nReturned, totalKeysExamined: e.totalKeysExamined, totalDocsExamined: e.totalDocsExamined,
   executionTimeMillis: e.executionTimeMillis })
Result
dob.age_1
{
  isCached: false,
  stage: 'FETCH',
  filter: { gender: { '$eq': 'female' } },
  nss: 'ch19.users',
  inputStage: {
    stage: 'IXSCAN',
    nss: 'ch19.users',
    keyPattern: { 'dob.age': 1 },
    indexName: 'dob.age_1',
    isMultiKey: false,
    multiKeyPaths: { 'dob.age': [] },
    isUnique: false,
    isSparse: false,
    isPartial: false,
    indexVersion: 2,
    direction: 'forward',
    indexBounds: { 'dob.age': [ '(60, inf]' ] }
  }
}
{
  nReturned: 15919,
  totalKeysExamined: 31858,
  totalDocsExamined: 31858,
  executionTimeMillis: 25
}
What’s happening
  1. The winning plan is IXSCAN on dob.age_1 with bounds (60, inf]: the index jumps straight to age 61 and reads to the end. The round bracket means 60 itself is excluded, matching $gt.
  2. FETCH loads each matching document and applies the remaining condition, filter: { gender: 'female' }, because the index knows nothing about gender.
  3. Result: 31,858 documents examined to return 15,919, the same shape as the 5,000 vs 1,222 in my notes. Half the fetched documents are men over 60, loaded only to be discarded.
  4. And it was slower than the collection scan (25 ms vs 17 ms). Matching 32% of the collection through an index means 31,858 random lookups, while a scan reads sequentially. An index helps when it's selective; a range that covers a third of the collection isn't.
  5. The fix is an index that covers both conditions, { gender: 1, "dob.age": 1 }. With it, the next example examines exactly 15,919 keys and documents.
AdvancedHow the planner picks a plan

When several indexes could serve a query, the planner runs the candidates side by side for a short trial period and keeps the one that produces results fastest. allPlansExecution shows the race, and hint() lets you overrule the planner:

mongosh
db.users.createIndex({ gender: 1, "dob.age": 1 })
const q = { "dob.age": { $gt: 60 }, gender: "female" }
const ap = db.users.find(q).explain("allPlansExecution").executionStats.allPlansExecution
ap.map(p => ({ index: p.executionStages.inputStage.indexName, works: p.executionStages.works,
               advanced: p.executionStages.advanced, keys: p.totalKeysExamined, docs: p.totalDocsExamined }))
const h = db.users.find(q).hint({ $natural: 1 }).explain("executionStats").executionStats
({ forced: "COLLSCAN", ms: h.executionTimeMillis, docs: h.totalDocsExamined })
const h2 = db.users.find(q).hint("dob.age_1").explain("executionStats").executionStats
({ forced: "dob.age_1", ms: h2.executionTimeMillis, keys: h2.totalKeysExamined })
db.users.find(q).itcount()
db.users.getPlanCache().list().map(e => ({ planCacheShapeHash: e.planCacheShapeHash, isActive: e.isActive, works: e.works }))
db.users.find(q).itcount()
db.users.getPlanCache().list().map(e => ({ planCacheShapeHash: e.planCacheShapeHash, isActive: e.isActive, works: e.works }))
Result
gender_1_dob.age_1
[
  {
    index: 'gender_1_dob.age_1',
    works: 101,
    advanced: 101,
    keys: 101,
    docs: 101
  },
  {
    index: 'dob.age_1',
    works: 101,
    advanced: 57,
    keys: 101,
    docs: 101
  }
]
{ forced: 'COLLSCAN', ms: 14, docs: 100000 }
{ forced: 'dob.age_1', ms: 17, keys: 31858 }
15919
[
  {
    planCacheShapeHash: 'B64D7A32',
    isActive: false,
    works: Long('101')
  }
]
15919
[
  {
    planCacheShapeHash: 'B64D7A32',
    isActive: true,
    works: Long('101')
  }
]
What’s happening
  1. Both candidate plans ran for 101 work units (the trial ends when one plan has produced 101 results, a full first batch, or after a work limit).
  2. In those 101 units the compound index produced 101 results and dob.age_1 only 57 (the rest were men it had to discard), so the compound index wins. Its plan was the gender_1_dob.age_1 result shown in the main example's step 5: 15,919 keys and documents for 15,919 results.
  3. hint({ $natural: 1 }) forces a collection scan, and hint("dob.age_1") forces the weaker index. Both still work, which is why hint is a debugging tool: it lets you measure the alternatives. Hard-coding hints in application code is a last resort, because the best plan changes as data changes.
  4. explain() doesn't touch the plan cache, but running the query does: after the first real execution the winner is cached per query shape (same fields and operators, any values) as an inactive entry. After the second run, which needed no more work than the first, it becomes active, and later queries of this shape skip the race. The cache is cleared when indexes change or the server restarts.
AdvancedStages you'll see in explain output
StageMeaning
COLLSCANRead every document. Fine for tiny collections; otherwise a missing index
IXSCANWalk an index between indexBounds
FETCHLoad the documents an IXSCAN pointed at (may apply a filter)
SORTBlocking in-memory sort: no index provided the order (100 MB limit)
LIMIT / SKIPApplied from .limit() / .skip()
PROJECTION_COVEREDProjection answered from the index alone: a covered query
PROJECTION_SIMPLE / PROJECTION_DEFAULTProjection applied to fetched documents
COUNT_SCANCount answered from the index without fetching
DISTINCT_SCANJump through distinct index values (distinct, $group with $first)
EXPRESS_IXSCAN8.0+ fast path for an equality lookup on _id or a unique single-field index (in this sandbox: find({ _id }) and find({ email }) once email was unique)
IDHACKThe _id fast path before 8.0
SHARD_MERGEResults merged from several shards (sharded clusters)
Read the numbers

An explain("executionStats") shows nReturned: 20, totalKeysExamined: 20, totalDocsExamined: 20, and a SORT stage above the FETCH. The query was find({ status: "active" }).sort({ createdAt: -1 }).limit(20). Is it well indexed?

Show answer

No. The counts look perfect, but the SORT stage gives it away, and the counts can't be right for this plan.

What’s happening
  1. A SORT stage means the order came from an in-memory sort, not from an index. To sort, the server must have read all active documents first, so totalKeysExamined would normally be the number of active documents, not 20.
  2. If it really is 20, then only 20 documents are active, and the query is fine for now and will degrade as data grows.
  3. Either way, the right index is { status: 1, createdAt: -1 }: equality first, then the sort field. Then the plan becomes LIMIT ← FETCH ← IXSCAN with no SORT, and it reads exactly 20 keys however many active documents there are. That's the ESR rule, next but one.

#Single-field and compound indexes

A single-field index sorts on one field: createIndex({ email: 1 }). The 1 means ascending and -1 descending; for a single field the direction doesn't matter, because the B-tree can be walked either way. A compound index sorts on several fields at once, in the order you list them: createIndex({ city: 1, "dob.age": 1 }) is sorted by city, and within each city by age.

The mental model is a phone book sorted by surname, then first name. It's perfect for "all the Smiths" and for "Smith, John". It's useless for "everyone called John", because the Johns are scattered across every surname. That's the prefix rule, and it's why my notes say "remember ORDER matters": a compound index can serve queries on any leading prefix of its fields (city, or city + age) but not on a later field alone (age).

On the 100,000 users, with one compound index { city: 1, "dob.age": 1 } and no other secondary index (how() runs explain("executionStats") and summarises the plan and counters):

mongosh
db.users.createIndex({ city: 1, "dob.age": 1 })
how({ city: "London" })
how({ city: "London", "dob.age": { $gte: 30, $lt: 40 } })
how({ "dob.age": { $gte: 30, $lt: 40 } })
Result
city_1_dob.age_1
{
  plan: 'FETCH <- IXSCAN(city_1_dob.age_1)',
  keys: 12512,
  docs: 12512,
  n: 12512
}
{
  plan: 'FETCH <- IXSCAN(city_1_dob.age_1)',
  keys: 2019,
  docs: 2019,
  n: 2019
}
{ plan: 'COLLSCAN', keys: 0, docs: 100000, n: 15986 }
What’s happening
  1. city alone is a prefix of the index, so the index is used: it reads the contiguous "London" block, 12,512 keys for 12,512 users.
  2. city + an age range uses both fields: inside the London block, the entries are sorted by age, so the scan starts at 30 and stops before 40. 2,019 keys for 2,019 results, perfectly tight.
  3. Age alone can't use the index: people aged 30–39 are spread across every city's block. The planner falls back to COLLSCAN, reading all 100,000 documents.
  4. So one compound index { city, age } replaces a single-field { city } index, but not an { age } one. Design compound indexes so the fields you always filter on come first.
AdvancedSort direction in compound indexes

For a single field, direction is irrelevant. For compound indexes it matters when you sort on several fields: the index can be walked forwards or backwards, so it supports the sort pattern it was built with and its exact reverse, and nothing else:

mongosh
how({ city: "London" }, { "dob.age": 1 })
how({ city: "London" }, { "dob.age": -1 })
how({}, { city: -1, "dob.age": -1 })
how({}, { city: 1, "dob.age": -1 })
Result
{
  plan: 'FETCH <- IXSCAN(city_1_dob.age_1)',
  keys: 12512,
  docs: 12512,
  n: 12512
}
{
  plan: 'FETCH <- IXSCAN(city_1_dob.age_1)',
  keys: 12512,
  docs: 12512,
  n: 12512
}
{
  plan: 'FETCH <- IXSCAN(city_1_dob.age_1)',
  keys: 100000,
  docs: 100000,
  n: 100000
}
{ plan: 'SORT <- COLLSCAN', keys: 0, docs: 100000, n: 100000 }
What’s happening
  1. Filtering on London and sorting by age (either direction) uses the index with no SORT stage: within one city, the entries are already in age order, read forwards or backwards.
  2. sort({ city: -1, "dob.age": -1 }) is the exact reverse of { city: 1, "dob.age": 1 }, so the server walks the whole index backwards. No in-memory sort, even with no filter.
  3. sort({ city: 1, "dob.age": -1 }) mixes directions. Walking the index in either direction gives ages ascending or cities descending, never this combination, so the planner falls back to SORT over a COLLSCAN.
  4. If you need that order, build the index to match: { city: 1, "dob.age": -1 }. Direction only matters for multi-field sorts with mixed directions.
AdvancedListing and dropping indexes

getIndexes() lists them (you'll always see the default _id_). You drop an index by its key pattern, as in my notes, or by its name:

mongosh
db.users.getIndexes()
db.users.createIndex({ "dob.age": 1 })
db.users.dropIndex({ "dob.age": 1 }).ok
db.users.dropIndex({ nothing: 1 }).ok
db.users.dropIndex("_id_")
db.users.createIndex({ city: 1, "dob.age": 1 }, { name: "city_age" })
Result
[
  { v: 2, key: { _id: 1 }, name: '_id_' },
  { v: 2, key: { city: 1, 'dob.age': 1 }, name: 'city_1_dob.age_1' }
]
dob.age_1
1
1
Uncaught MongoServerError[InvalidOptions]: cannot drop _id index
Uncaught MongoServerError[IndexOptionsConflict]: Index already exists with a different name: city_1_dob.age_1
What’s happening
  1. Default names are built from the key pattern: field_direction pairs joined by _, e.g. city_1_dob.age_1. v: 2 is the index format version.
  2. dropIndex({ "dob.age": 1 }) finds the index by key pattern and drops it. Dropping by name, dropIndex("dob.age_1"), is the same; dropIndexes() drops all except _id.
  3. Dropping an index that doesn't exist returned ok: 1. That's new: since MongoDB 8.3, dropIndexes is idempotent and ignores missing indexes. Before 8.3 it failed with IndexNotFound, which you'll still see on 8.0 and older servers.
  4. The _id index can never be dropped.
  5. You can't create the same key pattern twice under a different name, but you can create the same fields with a different direction, or with different options such as a partial filter, as a separate index.
Which queries can use the index?

With an index on { a: 1, b: 1, c: 1 }, which of these can use it efficiently: (1) { a: 5 }, (2) { b: 5 }, (3) { a: 5, c: 7 }, (4) { a: 5, b: { $gt: 1 } } sorted by c, (5) { c: 7, b: 2, a: 1 }?

Show answer

1, 3 (partly), 4 (partly) and 5. Not 2.

What’s happening
  1. { a: 5 } uses the prefix a: tight bounds.
  2. { b: 5 } has no a, so it isn't a prefix: no efficient use (at best a full index scan).
  3. { a: 5, c: 7 } uses a for the bounds; c can only be checked against index keys inside the a = 5 block, because b is skipped. Better than nothing, not tight.
  4. { a: 5, b: { $gt: 1 } } uses a and b for bounds, but sorting by c needs an in-memory SORT: after a range on b, the entries are ordered by b first, not by c. That's the ESR problem in the next topic.
  5. The order of fields in the query doesn't matter. { c, b, a } is the same filter as { a, b, c } and uses all three fields. Only the order in the index matters.

#The ESR rule

Added

ESR is MongoDB's guideline for ordering the fields of a compound index: Equality first, then Sort, then Range. Equality fields are matched exactly (city: "Toronto"), sort fields are in the sort(), and range fields use $gt, $lt, $in with a sort, $ne, regexes and the like.

Why that order? Think of a library shelved by section, then by author, then by year. If you want "the History section (equality), ordered by author (sort), published after 2000 (range)", a History → author → year shelf lets you walk the History aisle and already read books in author order, simply skipping old ones. Shelve it History → year → author instead, and the post-2000 books are in one block, but ordered by year: you'd have to carry them all to a table and sort them by author yourself. That table is the in-memory SORT stage. Equality narrows to one contiguous block, sort keeps that block in the right order, and the range is filtered while you walk it. Put the range before the sort and you break the order.

The query: Toronto users (equality) with a score of at least 900 (range), sorted by age (sort), first 20. Two indexes with the same fields in different orders, forced with hint() so we can compare them:

mongosh
db.users.createIndex({ city: 1, score: 1, "dob.age": 1 }, { name: "E_R_S" })
db.users.createIndex({ city: 1, "dob.age": 1, score: 1 }, { name: "E_S_R" })
// how(hint, limit) runs the query below with that index and summarises explain("executionStats")
// db.users.find({ city: "Toronto", score: { $gte: 900 } }).sort({ "dob.age": 1 }).hint(hint).limit(limit)
how("E_R_S", 20)
how("E_S_R", 20)
Result
E_R_S
E_S_R
{ plan: 'FETCH <- SORT <- IXSCAN', keys: 1850, docs: 20, n: 20, ms: 1 }
{ plan: 'LIMIT <- FETCH <- IXSCAN', keys: 21, docs: 20, n: 20, ms: 0 }
What’s happening
  1. E_R_S (city, score, age): the bounds city = Toronto, score ≥ 900 select one contiguous block of the index, but inside it the entries are ordered by score, not age. The server reads all 1,850 matching keys, sorts them by age in memory (SORT), then fetches the first 20.
  2. E_S_R (city, age, score): inside the Toronto block, entries are ordered by age, so they come out in the requested order. The server walks them, skips index entries with score < 900 without fetching anything, and stops after 20 matches: 21 keys, no SORT stage.
  3. Both return the same 20 users, but one reads 21 keys and the other 1,850, and the difference grows with the data. The ESR index also never hits the 100 MB in-memory sort limit, however many Toronto users there are.
  4. The SORT in E_R_S sits below FETCH: the sort key (age) is in the index, so the server sorts index entries and only fetches the 20 winners. Without age in the index it would have had to fetch all 1,850 documents first.
  5. Without hint(), the planner picks E_S_R on its own here. But it can only choose among indexes that exist, so designing them in ESR order is your job.
AdvancedWhen the range matches most of the block

The narrow range above (score ≥ 900 matches about 10% of Toronto) flatters E_R_S. With score ≥ 100, about 90% of Toronto users match:

mongosh
// same how() helper, with { city: "Toronto", score: { $gte: 100 } }
how("E_R_S", 20)
how("E_S_R", 20)
how("E_R_S")
how("E_S_R")
Result
{
  plan: 'FETCH <- SORT <- IXSCAN',
  keys: 16938,
  docs: 20,
  n: 20,
  ms: 6
}
{ plan: 'LIMIT <- FETCH <- IXSCAN', keys: 21, docs: 20, n: 20, ms: 0 }
{
  plan: 'FETCH <- SORT <- IXSCAN',
  keys: 16938,
  docs: 16938,
  n: 16938,
  ms: 28
}
{ plan: 'FETCH <- IXSCAN', keys: 17002, docs: 16938, n: 16938, ms: 10 }
What’s happening
  1. For a page of 20, E_R_S now reads and sorts 16,938 keys (6 ms); E_S_R still reads 21 (under 1 ms).
  2. Without a limit, both return all 16,938 documents, but E_R_S spends 28 ms because it sorts them all in memory, against 10 ms for E_S_R.
  3. E_S_R examined 17,002 keys for 16,938 results: the 64 extra are the seeks described next. Paying a few extra keys to avoid a blocking sort is the trade ESR makes.
AdvancedHow E_S_R skips the range field

With the range field last, the index bounds look like this:

mongosh
const e2 = db.users.find({ city: "Toronto", score: { $gte: 900 } }).sort({ "dob.age": 1 })
                   .hint("E_S_R").explain("executionStats").executionStats.executionStages
e2.inputStage.seeks
e2.inputStage.indexBounds
Result
64
{
  city: [ '["Toronto", "Toronto"]' ],
  'dob.age': [ '[MinKey, MaxKey]' ],
  score: [ '[900, inf]' ]
}
What’s happening
  1. The bounds are city = Toronto, any age, score ≥ 900. The server can't jump to one range, because the qualifying scores are scattered under every age.
  2. Instead it walks age by age: for each age it seeks to the first key with score ≥ 900, reads until the score range ends, then seeks to the next age. 64 seeks (one per distinct age, plus one) instead of reading every Toronto key.
  3. So E_S_R reads close to the minimum keys and delivers age order. When the sort field has a huge number of distinct values, the seeks cost more, which is one of the cases where you measure rather than follow the rule blindly.
AdvancedExceptions and fine print
  • $in is equality for filtering but a range for sorting. { city: { $in: ["Toronto", "Mumbai"] } } sorted by age needs the age-ordered streams of two cities merged; the planner can do that (a SORT_MERGE), but with many $in values it may fall back to a blocking sort. Treat a large $in as a range.
  • A very selective range can go before the sort. If the range matches only a handful of documents, sorting them in memory is cheap, and putting the range earlier gives tighter bounds. ESR is a default, not a law; compare plans with hint() and explain.
  • Equality fields among themselves: order them by what other queries need as a prefix. Selectivity matters much less than people think once all equality fields are in the bounds.
  • No sort? Then it's simply equality first, ranges last ("ER").
Design the index

Queries: find({ tenantId: 42, status: "open", createdAt: { $gte: lastWeek } }).sort({ priority: -1 }). Which index?

Show answer

{ tenantId: 1, status: 1, priority: -1, createdAt: 1 }.

What’s happening
  1. Equality: tenantId and status are exact matches, so they go first (either order; choose the one other queries can use as a prefix, usually tenantId).
  2. Sort: priority: -1 next, in the direction of the sort (or its reverse, if all fields flip), so results come out ordered without a SORT stage.
  3. Range: createdAt last; it's filtered on index keys while walking in priority order.
  4. Putting createdAt before priority would give tighter bounds but force an in-memory sort of every open ticket from the last week. Fine if that's always a few dozen, a problem if it's 100,000.

#Unique and partial indexes

A unique index ({ unique: true }) makes the database reject any write that would create two entries with the same key: the only reliable way to guarantee "one account per email". Checking in application code first (findOne, then insert) has a race: two requests can both see "not taken" and both insert. The index check happens atomically inside the write.

A partial index ({ partialFilterExpression: ... }) only includes documents that match a filter. It's smaller and cheaper to maintain, and combined with unique it lets you say "unique among the documents where this applies". Think of a guest list with a bouncer: a unique index is the bouncer checking names against the list, and a partial index is a VIP list that only bothers recording the people who matter for the question at hand.

Creating a unique index on email, then trying to insert a duplicate, and trying to make name unique when the data already has duplicates:

mongosh
db.users.createIndex({ email: 1 }, { unique: true })
db.users.insertOne({ name: "Copy Cat", email: "user1@example.com" })
db.users.createIndex({ name: 1 }, { unique: true })
Result
email_1
Uncaught MongoServerError: E11000 duplicate key error collection: ch19.users index: email_1 dup key: { email: "user1@example.com" }
Uncaught MongoServerError[DuplicateKey]: Index build failed: aecebbd9-3a02-4c98-af8b-fe8b79e05dad: Collection ch19.users ( 0e425f4c-c164-42e4-81a9-8b30917662b4 ) :: caused by :: E11000 duplicate key error collection: ch19.users index: name_1 dup key: { name: "Aarav Ali" }
What’s happening
  1. The 100,000 emails are all different, so the unique index builds.
  2. Inserting a second user1@example.com fails with E11000 duplicate key error, the code your application should catch (error code 11000) to tell the user "email already registered". Nothing is written.
  3. Making name unique fails at build time: the generated data has many users called "Aarav Ali". MongoDB checks the existing documents, finds a duplicate and aborts the build. Since 7.1, most build errors are reported as soon as they're found during the scan, but duplicate key errors still surface at commit time, at the end of the build.
  4. To add a unique constraint to existing data, find the duplicates first ($group by the field, $match: { count: { $gt: 1 } }), fix them, then build.
  5. Uniqueness is per index key. A unique compound index { tenantId: 1, email: 1 } allows the same email in different tenants.
AdvancedEdge case: unique indexes and missing fields

A unique index treats a missing field as null, and null is a value like any other, so only one document may lack the field:

mongosh
db.accounts.createIndex({ phone: 1 }, { unique: true })
db.accounts.insertOne({ _id: 1, phone: "+1-416-555-0101" })
db.accounts.insertOne({ _id: 2 })
db.accounts.insertOne({ _id: 3 })
db.accounts.dropIndex("phone_1").ok
db.accounts.createIndex({ phone: 1 }, { unique: true, partialFilterExpression: { phone: { $type: "string" } } })
db.accounts.insertOne({ _id: 3 })
db.accounts.insertOne({ _id: 4, phone: "+1-416-555-0101" })
db.accounts.countDocuments()
Result
phone_1
{ acknowledged: true, insertedId: 1 }
{ acknowledged: true, insertedId: 2 }
Uncaught MongoServerError: E11000 duplicate key error collection: ch19.accounts index: phone_1 dup key: { phone: null }
1
phone_1
{ acknowledged: true, insertedId: 3 }
Uncaught MongoServerError: E11000 duplicate key error collection: ch19.accounts index: phone_1 dup key: { phone: "+1-416-555-0101" }
3
What’s happening
  1. Account 2 has no phone; the index stores it under the key null.
  2. Account 3 also has no phone, so it's a second null: E11000 … dup key: { phone: null }. "Phone is optional but unique if given" doesn't work with a plain unique index.
  3. The fix is a partial unique index that only includes documents whose phone is a string. Accounts without a phone aren't in the index at all, so any number of them can exist: account 3 now inserts.
  4. Real duplicates are still rejected: account 4 reuses account 1's number and fails with dup key: { phone: "+1-416-555-0101" }. Three accounts remain: one with a phone, two without.
  5. The older way was { unique: true, sparse: true }; a sparse index skips documents that lack the field. Partial indexes (3.2+) are the general, preferred form.
AdvancedPartial index: only the documents you query

My notes' example: an index on dob.age that only contains male users. The index is smaller, but the planner can only use it when the query guarantees it only wants documents inside the filter:

mongosh
db.users.createIndex({ "dob.age": 1 }, { partialFilterExpression: { gender: "male" } })
how({ "dob.age": { $gt: 70 }, gender: "male" })
how({ "dob.age": { $gt: 70 }, gender: "female" })
how({ "dob.age": { $gt: 70 } })
db.users.createIndex({ "dob.age": 1 }, { name: "age_full" })
db.adminCommand({ fsync: 1 }).ok
db.users.stats().indexSizes
Result
dob.age_1
{ plan: 'FETCH <- IXSCAN(dob.age_1)', keys: 8019, docs: 8019, n: 8019 }
{ plan: 'COLLSCAN', keys: 0, docs: 100000, n: 7874 }
{ plan: 'COLLSCAN', keys: 0, docs: 100000, n: 15893 }
age_full
1
{ _id_: 3575808, 'dob.age_1': 241664, age_full: 466944 }
What’s happening
  1. Men over 70: the query includes gender: "male", which matches the partial filter exactly, so the index is used: 8,019 keys for 8,019 results.
  2. Women over 70: those documents aren't in the index, so it can't be used. COLLSCAN.
  3. Everyone over 70, without a gender condition: the index would miss the women, so the planner won't use it. It must prove the query is a subset of the filter. This is the classic partial index surprise: "I created an index on age and my age query doesn't use it".
  4. Sizes (in bytes, after fsync forces a checkpoint so the statistics are current): the partial index is 242 KB against 467 KB for a full index on the same field, because it holds about half the keys. On a collection where the filter matches 2% of documents (say, status: "active"), the saving is far bigger.
  5. Note age_full could coexist with the partial dob.age_1: same key pattern, different options, different name.
AdvancedTip: tell users which field was taken
Spot the bug: the partial index is never used
mongosh
db.orders.createIndex({ customerId: 1 }, { partialFilterExpression: { status: { $in: ["paid", "shipped"] } } })
db.orders.find({ customerId: 2 })
Show answer

The query doesn't include a condition on status, so the planner can't prove every match is in the index. It ignores the partial index and scans.

What’s happening
  1. The index only contains paid and shipped orders.
  2. find({ customerId: 2 }) asks for all of customer 2's orders, including any pending or cancelled ones, which aren't in the index.
  3. Using the index could silently drop results, so the planner won't.
  4. Fix: add the filter to the query, find({ customerId: 2, status: { $in: ["paid", "shipped"] } }) (or a condition that implies it, such as status: "paid"), or use a full index if the application needs all statuses.

#TTL indexes

A TTL (time-to-live) index makes MongoDB delete documents automatically once they're older than a given age. You create it on a date field with expireAfterSeconds; a background thread, the TTL monitor, wakes up periodically and deletes every document whose date plus expireAfterSeconds is in the past. Typical uses: sessions, password-reset tokens, one-time codes, caches, logs you keep for 30 days.

It works like a parking warden on rounds rather than a meter that ejects your car the second it expires. The warden passes every 60 seconds (that's the ttlMonitorSleepSecs default, and what my notes mean by "the cleanup process runs every 60 seconds"), so an expired document lives on until the next pass, and longer if the server is busy. Never rely on TTL for exact expiry: if a token must stop working at a precise moment, also check the expiry in the query.

My notes' example: an index on createdAt that expires after 10 seconds. Here are 5 sessions plus two documents with no usable date, polled every 2 seconds until the TTL monitor removes them:

mongosh
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 10 })
const t0 = Date.now()
db.sessions.insertMany([1, 2, 3, 4, 5].map(i => ({ user: "u" + i, createdAt: new Date() }))).acknowledged
db.sessions.insertMany([ { user: "no-date" }, { user: "string-date", createdAt: "2020-01-01" } ]).acknowledged
const ttlBefore = db.serverStatus().metrics.ttl
let last = -1
for (let i = 0; i < 45; i++) {
  const n = db.sessions.countDocuments()
  if (n !== last) { print(`t+${Math.round((Date.now() - t0) / 1000)}s: ${n} documents`); last = n }
  if (n === 2) break
  sleep(2000)
}
const ttlAfter = db.serverStatus().metrics.ttl
({ ttlPasses: ttlAfter.passes - ttlBefore.passes, ttlDeleted: ttlAfter.deletedDocuments - ttlBefore.deletedDocuments })
db.sessions.find({}, { _id: 0 })
Result
createdAt_1
true
true
t+0s: 7 documents
t+50s: 2 documents
2
{ ttlPasses: 1, ttlDeleted: 5 }
[
  { user: 'no-date' },
  { user: 'string-date', createdAt: '2020-01-01' }
]
What’s happening
  1. The index is an ordinary ascending index on createdAt, plus the expireAfterSeconds: 10 option that turns it into a TTL index.
  2. Five sessions get createdAt = now, so they become eligible for deletion at t+10s. Two more documents have no date (no-date) or a string instead of a date (string-date).
  3. Nothing happens at t+10s. The documents survive until the TTL monitor's next pass, which came at t+50s: all 5 sessions disappeared at once. Across three runs of this script, the deletion came at t+14s, t+50s and t+60s: always after expiry, up to a minute late.
  4. serverStatus().metrics.ttl confirms one TTL pass and 5 deletions during the test. (Those counters are server-wide: in one run other databases' TTL deletions were counted too.)
  5. The two other documents are never deleted: a TTL index only expires documents whose field holds a BSON date (or an array of dates). A missing field or a string date is silently ignored, so store real dates, ideally enforced with schema validation.
  6. expireAfterSeconds counts from the stored date, not from insertion. A document inserted with createdAt an hour in the past is deleted on the very next pass.
AdvancedExpire at a specific time: expireAfterSeconds 0

To give each document its own expiry time, store the expiry moment itself and use expireAfterSeconds: 0:

mongosh
db.resetTokens.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
const t0 = Date.now()
db.resetTokens.insertMany([
  { token: "a1", expireAt: new Date(t0 + 5000) },
  { token: "b2", expireAt: new Date(t0 + 30000) },
  { token: "c3", expireAt: new Date(t0 + 3600000) }
]).acknowledged
let last = -1
for (let i = 0; i < 70; i++) { const left = db.resetTokens.find({}, { _id: 0, token: 1 }).toArray().map(d => d.token).join(","); if (left !== last) { print(`t+${Math.round((Date.now() - t0) / 1000)}s: ${left}`); last = left } if (left === "c3") break; sleep(2000) }
db.resetTokens.find({ expireAt: { $gt: new Date() } }, { _id: 0, token: 1 })
Result
expireAt_1
true
t+0s: a1,b2,c3
t+50s: c3
c3
[ { token: 'c3' } ]
What’s happening
  1. With expireAfterSeconds: 0, a document expires exactly at its expireAt value. a1 is due at t+5s, b2 at t+30s, c3 in an hour.
  2. Both a1 and b2 were removed in the same pass at t+50s. Between t+30s and t+50s, b2 had expired but still existed: a 20-second window in which an expired token was still in the database.
  3. That's why the last query filters on expireAt: { $gt: new Date() } as well. Treat TTL as garbage collection, and enforce expiry in the query.
  4. The per-document pattern is also more flexible: different tokens can live for different times, and you extend one by updating its expireAt.
AdvancedChanging a TTL and what isn't allowed

You can't change expireAfterSeconds by calling createIndex again; use collMod (5.1+ can also turn an ordinary single-field index into a TTL index this way). Compound and _id TTL indexes are rejected:

mongosh
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 })
const r = db.runCommand({ collMod: "sessions", index: { keyPattern: { createdAt: 1 }, expireAfterSeconds: 3600 } })
({ old: r.expireAfterSeconds_old, new: r.expireAfterSeconds_new })
db.sessions.createIndex({ user: 1, createdAt: 1 }, { expireAfterSeconds: 60 })
db.sessions.createIndex({ _id: 1 }, { expireAfterSeconds: 60 })
db.adminCommand({ getParameter: 1, ttlMonitorSleepSecs: 1 }).ttlMonitorSleepSecs
Result
Uncaught MongoServerError[IndexOptionsConflict]: An equivalent index already exists with the same name but different options. Requested index: { v: 2, key: { createdAt: 1 }, name: "createdAt_1", expireAfterSeconds: 3600 }, existing index: { v: 2, key: { createdAt: 1 }, name: "createdAt_1", expireAfterSeconds: 10 }
{ old: Long('10'), new: Long('3600') }
Uncaught MongoServerError[CannotCreateIndex]: TTL indexes are single-field indexes, compound indexes do not support TTL. Index spec: { expireAfterSeconds: 60, name: "user_1_createdAt_1", key: { user: 1, createdAt: 1 } }
Uncaught MongoServerError[InvalidIndexSpecificationOption]: The field 'expireAfterSeconds' is not valid for an _id index specification. Specification: { expireAfterSeconds: 60, name: "_id_1", key: { _id: 1 }, v: 2 }
60
What’s happening
  1. createIndex with a new expireAfterSeconds conflicts with the existing index: IndexOptionsConflict.
  2. collMod changes it in place and reports old and new values: 10 → 3600 seconds. Shortening a TTL on a big collection can make millions of documents eligible at once, so the docs advise deleting in batches first.
  3. A compound index with expireAfterSeconds is refused in 9.0 with CannotCreateIndex. (The TTL page of the manual still says compound indexes simply ignore the option; the server is stricter than the docs.)
  4. _id can't be a TTL field. Use a separate date field.
  5. ttlMonitorSleepSecs is 60: the pass interval. It's a server parameter you can change, but shorter intervals cost more and still don't make expiry exact.
AdvancedHow the TTL monitor actually works
  • It runs on the primary only; secondaries apply the deletes through replication like any other delete.
  • Deletes are ordinary deletes: they hit the oplog, take locks, and trigger change-stream events (an operationType: "delete", useful for "session expired" side effects).
  • Since 6.1, TTL deletions can be batched (a BATCHED_DELETE stage). Each pass also gives each TTL index a budget (up to 50,000 documents or one second) before moving on to the next, so one huge expiring collection can't starve the others.
  • On a large collection, creating a TTL index (or lowering expireAfterSeconds) can trigger a big wave of deletions; do it off-peak.
  • Time series collections (5.0+) have their own expireAfterSeconds collection option, which drops whole buckets instead of deleting documents one by one: much cheaper for metrics.
  • Mass deletion leaves free space inside the data files; WiredTiger reuses it for new writes. See Building and maintaining indexes for what compact does.
Predict the result: the TTL that never fires
mongosh
db.events.createIndex({ ts: 1 }, { expireAfterSeconds: 60 })
db.events.insertOne({ type: "login", ts: Date.now() })

Two minutes later, is the document still there?

Show answer

Yes, and it stays there forever.

What’s happening
  1. Date.now() returns a number (milliseconds since 1970), not a BSON date.
  2. The TTL monitor only expires documents whose indexed field is a date or an array of dates. A number is silently ignored, exactly like the string-date document in the main example.
  3. Use new Date() in mongosh/Node (or datetime in Python) so the value is stored as a BSON date. Schema validation with bsonType: "date" on the field catches this at insert time.

#Multikey indexes

When you index a field that holds an array, MongoDB creates one index entry per array element. Such an index is called multikey, and MongoDB makes an index multikey automatically the first time it sees an array in that field; there's no option to set. That's what makes queries like { hobbies: "chess" } ("documents whose hobbies array contains chess") fast.

The mental model is a book's subject index where one page appears under several subjects: a page about "indexing in MongoDB and MySQL" is listed under both MongoDB and MySQL. A user with hobbies ["chess", "cricket", "music"] appears three times in the hobbies index, once under each value, all pointing at the same document.

mongosh
db.users.createIndex({ hobbies: 1 })
how({ hobbies: "chess" })
how({ hobbies: { $all: ["chess", "cricket"] } })
how({ hobbies: { $in: ["chess", "cricket"] } })
Result
hobbies_1
{
  plan: 'FETCH <- IXSCAN(hobbies_1)',
  isMultiKey: true,
  keys: 24808,
  docs: 24808,
  n: 24808
}
{
  plan: 'FETCH <- IXSCAN(hobbies_1)',
  isMultiKey: true,
  keys: 24808,
  docs: 24808,
  n: 5620
}
{
  plan: 'FETCH <- IXSCAN(hobbies_1)',
  isMultiKey: true,
  keys: 49923,
  docs: 44301,
  n: 44301
}
What’s happening
  1. isMultiKey: true: the server noticed hobbies holds arrays and flagged the index. Each user has 1 to 4 hobbies (2.5 on average), so the index has about 250,000 entries.
  2. { hobbies: "chess" } looks up the single key "chess": 24,808 users, each fetched once.
  3. $all: ["chess", "cricket"] uses the index for one of the values (chess, 24,808 keys) and checks the second on each fetched document: only 5,620 users have both. A compound or intersection plan isn't used here, so $all with a common first value can examine a lot.
  4. $in reads two key ranges, 49,923 keys in total, but some users have both hobbies and appear under both keys. The server de-duplicates by document, so only 44,301 documents are fetched and returned once each.
  5. Nothing in the query says "array": the same syntax works whether hobbies is a string or an array. That's MongoDB's array-matching rule, and the multikey index follows it.
AdvancedIndexes on fields inside arrays of documents

Indexing a field of embedded documents in an array, like items.productId in the orders, is also multikey. It's how "find all orders containing product 7" stays fast:

mongosh
db.orders.createIndex({ "items.productId": 1 })
const leaf = (stage) => stage.inputStage ? leaf(stage.inputStage) : stage
const s = leaf(db.orders.find({ "items.productId": 7 }).explain().queryPlanner.winningPlan)
({ stage: s.stage, index: s.indexName, isMultiKey: s.isMultiKey, multiKeyPaths: s.multiKeyPaths })
db.orders.find({ "items.productId": 7 }, { _id: 1 })
db.orders.find({ "items.productId": 7, "items.qty": { $gte: 2 } }, { _id: 1 })
db.orders.find({ items: { $elemMatch: { productId: 7, qty: { $gte: 2 } } } }, { _id: 1 })
Result
items.productId_1
{
  stage: 'IXSCAN',
  index: 'items.productId_1',
  isMultiKey: true,
  multiKeyPaths: { 'items.productId': [ 'items' ] }
}
[ { _id: 1 }, { _id: 5 }, { _id: 8 } ]
[ { _id: 1 }, { _id: 5 }, { _id: 8 } ]
[ { _id: 1 }, { _id: 8 } ]
What’s happening
  1. multiKeyPaths says which part of the path is an array: items. The planner uses this to know which bounds it may combine.
  2. Orders 1, 5 and 8 contain the USB-C charger (product 7).
  3. The second query looks like "product 7 with quantity ≥ 2", but each condition is checked against any element: order 5 has product 7 with qty: 1 and a different line (product 6) with qty: 2, so it matches.
  4. $elemMatch requires both conditions on the same element, and order 5 drops out. Order 1 (2 chargers) and order 8 (4 chargers) remain. This is the same rule as in Querying arrays: $size, $all and $elemMatch; a multikey index serves both forms.
AdvancedEdge case: parallel arrays can't share a compound index

A compound multikey index may contain at most one array field per document. Indexing two arrays together would need one entry for every combination of elements (a cross product), so MongoDB refuses:

mongosh
db.multi.createIndex({ tags: 1, sizes: 1 })
db.multi.insertOne({ tags: ["a", "b"], sizes: [1, 2] })
db.multi.insertOne({ tags: ["a", "b"], sizes: 1 })
db.multi.insertOne({ tags: "a", sizes: [1, 2] })
Result
tags_1_sizes_1
Uncaught MongoServerError: cannot index parallel arrays [sizes] [tags]
{
  acknowledged: true,
  insertedId: ObjectId('6ad246f82c97293756ae8909')
}
{
  acknowledged: true,
  insertedId: ObjectId('6ad246f82c97293756ae890a')
}
What’s happening
  1. Creating the index succeeds: MongoDB can't know in advance which fields will hold arrays.
  2. A document where both tags and sizes are arrays is rejected at insert time: "cannot index parallel arrays".
  3. Documents where only one of the two is an array are fine, so the restriction is per document, which makes it an easy production surprise: the first document that happens to have both as arrays fails.
  4. If you need to query both arrays, use two single-field indexes, or restructure (e.g. an array of { tag, size } sub-documents, indexed as { "variants.tag": 1, "variants.size": 1 }, where both paths are inside the same array and that is allowed).
AdvancedMultikey limitations to know
  • A multikey index can't be the shard key index.
  • It can't cover a query on the array field itself, because an index entry holds one element, not the whole array (see Covered queries). It can cover the other, non-array fields of a compound index.
  • Sorting on an array field sorts by the smallest (ascending) or largest (descending) element, and an index on the array can't always provide that order without a blocking sort.
  • Range bounds on an array field can't always be intersected: { scores: { $gt: 5, $lt: 10 } } matches if some element is above 5 and some (maybe another) element is below 10; use $elemMatch to force both on one element and get tight bounds.
  • Once an index is multikey it stays flagged (until rebuilt), even if all array documents are removed.

#Covered queries

A query is covered when the index alone contains everything the query needs: every field in the filter and every field in the projection. Then MongoDB never reads the documents at all: totalDocsExamined is 0. My notes put it exactly: "documents fetched is 0 in executionStats… all the data could be fetched from the index itself".

Think of a library catalogue card that already shows the title, author and year. If those three things are all you want, you read them off the card and never walk to the shelf. Ask for the first sentence of chapter 3 as well and you have to go and fetch the book. In MongoDB terms, the plan loses its FETCH stage and gains a PROJECTION_COVERED stage. The catch everyone forgets: _id is returned by default, and unless _id is in the index, including it forces a fetch.

With an index on { city: 1, score: 1 }:

mongosh
db.users.createIndex({ city: 1, score: 1 })
how({ city: "Toronto", score: { $gte: 990 } }, { _id: 0, city: 1, score: 1 })
how({ city: "Toronto", score: { $gte: 990 } }, { city: 1, score: 1 })
how({ city: "Toronto", score: { $gte: 990 } }, { _id: 0, city: 1, score: 1, name: 1 })
db.users.find({ city: "Toronto", score: { $gte: 990 } }, { _id: 0, city: 1, score: 1 }).limit(3)
Result
city_1_score_1
{ plan: 'PROJECTION_COVERED <- IXSCAN', keys: 200, docs: 0, n: 200, ms: 0 }
{
  plan: 'PROJECTION_SIMPLE <- FETCH <- IXSCAN',
  keys: 200,
  docs: 200,
  n: 200,
  ms: 0
}
{
  plan: 'PROJECTION_SIMPLE <- FETCH <- IXSCAN',
  keys: 200,
  docs: 200,
  n: 200,
  ms: 0
}
[
  { city: 'Toronto', score: 990 },
  { city: 'Toronto', score: 990 },
  { city: 'Toronto', score: 990 }
]
What’s happening
  1. Filter on city and score, project only city and score with _id: 0: everything is in the index. Plan PROJECTION_COVERED <- IXSCAN, 200 keys, 0 documents examined. That's a covered query.
  2. Same query without _id: 0: the default _id isn't in the index, so each result needs a FETCH: 200 documents examined.
  3. Adding name to the projection has the same effect: name isn't in the index.
  4. The returned documents are built from index keys, which is why they contain exactly city and score.
  5. Covered queries are the biggest win for hot, read-heavy queries that need only a few fields, such as autocomplete, "does this exist?" checks, or listing ids and labels.
AdvancedHow much a fetch costs

At 200 results the difference doesn't show in milliseconds. With a wider range, about 17,000 Toronto users, it does:

mongosh
how({ city: "Toronto", score: { $gte: 100 } }, { _id: 0, city: 1, score: 1 })
how({ city: "Toronto", score: { $gte: 100 } }, { _id: 0, city: 1, score: 1, name: 1 })
Result
{
  plan: 'PROJECTION_COVERED <- IXSCAN',
  keys: 16938,
  docs: 0,
  n: 16938,
  ms: 3
}
{
  plan: 'PROJECTION_SIMPLE <- FETCH <- IXSCAN',
  keys: 16938,
  docs: 16938,
  n: 16938,
  ms: 12
}
What’s happening
  1. Both read the same 16,938 index keys.
  2. The covered version stops there: 3 ms.
  3. Asking for name adds 16,938 document fetches: 12 ms, four times slower, and that's with every document already in RAM. When documents have to come from disk, the gap grows to orders of magnitude, because each fetch is a random read.
  4. A second run gave the same 3 ms vs 11 ms.
AdvancedEdge case: covering non-array fields

A multikey index can't return the array field (an entry holds a single element), but it can cover the other fields of the index:

mongosh
db.users.createIndex({ hobbies: 1, city: 1 })
how({ hobbies: "chess", city: "Dubai" }, { _id: 0, hobbies: 1, city: 1 })
how({ hobbies: "chess", city: "Dubai" }, { _id: 0, city: 1 })
Result
hobbies_1_city_1
{
  plan: 'PROJECTION_SIMPLE <- FETCH <- IXSCAN',
  keys: 1547,
  docs: 1547,
  n: 1547,
  ms: 2
}
{
  plan: 'PROJECTION_COVERED <- IXSCAN',
  keys: 1547,
  docs: 0,
  n: 1547,
  ms: 0
}
What’s happening
  1. Projecting hobbies needs the whole array, but each index entry only knows one hobby, so the documents must be fetched.
  2. Projecting only city (still filtering on hobbies) is covered: docs: 0. The filter on hobbies is answered by the index bounds, and city is read from the key.
  3. Other things that prevent covering: a filter or projection on a field not in the index, a field inside an embedded document when the index is on the whole sub-document, and $elemMatch projections.
Is this query covered?

Index: { email: 1 }. Query: db.users.find({ email: "user5@example.com" }, { email: 1 }).

Show answer

No. The projection returns _id by default, and _id isn't in the { email: 1 } index.

What’s happening
  1. The filter field email is in the index, and the projection asks for email, which is in the index too.
  2. But the projection also includes _id, because _id is always returned unless excluded. My notes: "_id will always be returned… if you don't want it, set it to 0".
  3. To fetch _id, the server must load the document: a FETCH stage, 1 document examined.
  4. Either project { _id: 0, email: 1 }, or build the index as { email: 1, _id: 1 } if you need _id too.

#Wildcard indexes

Added

A wildcard index (MongoDB 4.2+) indexes every field under a path, including fields you don't know in advance: createIndex({ "specs.$**": 1 }) indexes specs.ramGB, specs.cpu, specs.watts, specs.pages and whatever other attribute appears later. It exists for documents with unpredictable or user-defined fields: product attributes that vary by category, user-defined custom fields in a SaaS app, event payloads.

Think of a filing cabinet that grows a new labelled drawer automatically whenever a document arrives with a field nobody has seen before. Normally you'd have to know the fields up front to create one index per attribute; with a hundred possible attributes, that's a hundred indexes. A wildcard index is one index that behaves like many single-field indexes, but each query uses it for one field at a time, so it doesn't replace well-designed compound indexes for known query patterns.

A catalog collection of 50,000 products of four kinds, each with a different shape of specs:

mongosh
db.catalog.findOne({ kind: "laptop" }, { _id: 0 })
db.catalog.createIndex({ "specs.$**": 1 })
how({ "specs.ramGB": 32 })
how({ "specs.watts": { $gte: 65 } })
how({ "specs.format": "ebook", "specs.pages": { $lt: 200 } })
how({ kind: "phone" })
Result
{
  sku: 'SKU0',
  price: 33,
  kind: 'laptop',
  specs: { ramGB: 8, cpu: 'M4', screenIn: 16 }
}
specs.$**_1
{
  plan: 'FETCH <- IXSCAN(specs.$**_1)',
  keys: 4187,
  docs: 4187,
  n: 4187
}
{
  plan: 'FETCH <- IXSCAN(specs.$**_1)',
  keys: 6243,
  docs: 6243,
  n: 6243
}
{
  plan: 'FETCH <- IXSCAN(specs.$**_1)',
  keys: 1374,
  docs: 1374,
  n: 468
}
{ plan: 'COLLSCAN', keys: 0, docs: 50000, n: 12500 }
What’s happening
  1. Laptops have ramGB, cpu and screenIn; phones storageGB and fiveG; books pages and format; chargers watts and ports. Nine different attribute fields.
  2. One wildcard index serves an equality query on specs.ramGB (4,187 laptops with 32 GB) and a range on specs.watts (6,243 chargers of 65 W or more), with tight keys = docs = results.
  3. Two specs conditions at once: the index is used for one of them (here 1,374 ebooks), and the other (pages < 200) is checked on each fetched document, leaving 468. A compound index { "specs.format": 1, "specs.pages": 1 } would read exactly 468 keys.
  4. kind is outside specs.$**, so a query on it can't use the wildcard index: COLLSCAN.
  5. Internally, each key is stored as the pair (field path, value), so a lookup on specs.ramGB = 32 is a range scan inside the specs.ramGB part of the index.
AdvancedCompound wildcard indexes and limits

MongoDB 7.0 added compound wildcard indexes: ordinary fields plus one wildcard term, so "filter by kind, then any spec" is a single tight index:

mongosh
db.catalog.find({ "specs.cpu": "M4" }).sort({ "specs.ramGB": 1 }).explain().queryPlanner.winningPlan.stage
db.catalog.createIndex({ kind: 1, "specs.$**": 1 })
how({ kind: "laptop", "specs.cpu": "M4" })
db.adminCommand({ fsync: 1 }).ok
db.catalog.stats().indexSizes
Result
SORT
kind_1_specs.$**_1
{
  plan: 'FETCH <- IXSCAN(kind_1_specs.$**_1)',
  keys: 4104,
  docs: 4104,
  n: 4104
}
1
{ _id_: 2084864, 'specs.$**_1': 708608, 'kind_1_specs.$**_1': 704512 }
What’s happening
  1. Filtering on one spec and sorting on another spec can't be served by the wildcard index (each entry is one field), so the plan has a blocking SORT.
  2. The compound wildcard index { kind: 1, "specs.$**": 1 } uses kind and one spec field together: 4,104 M4 laptops, examined exactly.
  3. The sizes (bytes, after a checkpoint) show each wildcard index is about a third of the size of the _id index here, even though it holds 2 to 3 keys per product: keys that share a field-path prefix compress very well.
  4. Other rules: a wildcard index can't be unique or TTL, can't be a shard key, indexes array elements (like a multikey index), and you can narrow it with wildcardProjection (e.g. index all fields except some) when you index the whole document with { "$**": 1 }.
  5. Prefer the attribute pattern (attrs: [{ k: "ramGB", v: 32 }] with an index on { "attrs.k": 1, "attrs.v": 1 }) when you need several attribute conditions in one query; see Schema design patterns.

#Building and maintaining indexes

Creating an index on an existing collection means reading every document and building a B-tree from scratch, while the application keeps reading and writing. How MongoDB does that changed in 4.2, and my notes describe the old world: "foreground vs background index builds — foreground: blocks any activity in the collection". That was true up to MongoDB 4.0, and it's still asked about, so here are both.

Before 4.2 you chose: a foreground build was fast and produced a compact index, but held an exclusive lock that blocked all reads and writes on the database for the whole build; a background build ({ background: true }) allowed reads and writes but was slower and produced a less efficient index. Since 4.2 there is only one kind, an optimised "hybrid" build: it takes an exclusive lock only briefly at the start and end, yields to reads and writes in between, records writes that happen during the build in a side table and applies them before committing. The background option is ignored. Think of it as resurfacing a road one lane at a time with a short full closure to set up and another to remove the cones, instead of closing it for the whole job.

mongosh
const t = Date.now(); db.users.createIndex({ city: 1, score: -1 }, { background: true }); ({ buildMs: Date.now() - t })
db.users.getIndexes().find(i => i.name === "city_1_score_-1")
Result
{ buildMs: 157 }
{
  v: 2,
  key: { city: 1, score: -1 },
  name: 'city_1_score_-1',
  background: true
}
What’s happening
  1. The build over 100,000 documents took 157 ms. background: true was accepted and even stored in the index spec, but it changes nothing: the server log (below) shows the build ran with "method":"hybrid", the only method since 4.2.
  2. Commit quorum (4.4+): on a replica set, the index is built on all data-bearing voting members in parallel, and the primary commits it only when enough of them are ready, votingMembers (all of them) by default. This sandbox is a one-member replica set, so the quorum is just localhost:27017.
  3. While the build ran, writes to users were allowed; any that happened went to a side-writes table and were applied before the commit.
  4. If a unique index finds duplicates, the build fails at the end (as in Unique and partial indexes) rather than leaving a half-built index.
  5. Practical consequence: you can create indexes on a live system, but a big build still costs I/O and CPU, and on a write-heavy collection it's still best done in a quiet period.
AdvancedThe build in the server log

The mongod log for that build, trimmed to the messages and key attributes (docker compose logs mongo), shows the phases:

mongod log (trimmed)
"msg":"Index build: starting", ... "properties":{"v":2,"key":{"city":1,"score":-1},"name":"city_1_score_-1","background":true}, ... "method":"hybrid"
"msg":"Index build: commit quorum satisfied", ... "commitQuorum":"votingMembers","indexNames":["city_1_score_-1"],"commitReadyMembers":["localhost:27017"]
"msg":"Index build: done building", ... "index":"city_1_score_-1"
"msg":"Index build: completed successfully", ... "numIndexesBefore":1,"numIndexesAfter":2
AdvancedHidden indexes: test a drop without dropping

Rebuilding a large index takes time, so dropping the wrong one is expensive. Since 4.4 you can hide an index: it's still maintained on every write, but the planner ignores it. If nothing gets slower, drop it; if something does, unhide it instantly:

mongosh
plan({ city: "Dubai" })
db.users.hideIndex("city_1_score_-1").ok
plan({ city: "Dubai" })
db.users.getIndexes().find(i => i.name === "city_1_score_-1").hidden
db.users.unhideIndex("city_1_score_-1").ok
plan({ city: "Dubai" })
Result
FETCH <- IXSCAN(city_1_score_-1)
1
COLLSCAN
true
1
FETCH <- IXSCAN(city_1_score_-1)
What’s happening
  1. plan() prints the winning plan's stages. With the index visible, the Dubai query uses it.
  2. After hideIndex, the same query falls back to COLLSCAN: exactly what would happen if you dropped it, so you can watch latency and the slow-query log.
  3. The index is flagged hidden: true, and it's still updated on every write, so unhiding is instant, with no rebuild.
  4. You can't hide the _id index.
AdvancedFinding unused indexes with $indexStats

$indexStats reports how often each index has been used since the server started (or since the index was created):

mongosh
db.users.find({ city: "Dubai" }).itcount()
db.users.aggregate([ { $indexStats: {} }, { $project: { _id: 0, name: 1, ops: "$accesses.ops", since: "$accesses.since" } } ])
Result
6220
[
  {
    name: 'city_1_score_-1',
    ops: Long('1'),
    since: ISODate('2026-10-16T15:51:50.050Z')
  },
  {
    name: '_id_',
    ops: Long('0'),
    since: ISODate('2026-10-16T15:47:40.063Z')
  }
]
What’s happening
  1. The Dubai query used city_1_score_-1 once, so its ops is 1 (the explain calls don't count).
  2. _id_ has 0 operations: nothing looked up a user by _id in this session. Note the since of city_1_score_-1 (15:51:50) is when it was unhidden in the previous example, not when it was built (15:50:59 in the log): hiding and unhiding resets the counters.
  3. Counters reset on restart and are per member of a replica set, so check every member over a representative period (a full business cycle, including month-end jobs) before concluding an index is unused. Then hide it, wait, and drop it.
AdvancedCorrecting my notes: compact and reIndex

My notes say to "periodically run compact and reIndex to optimize index storage". That's outdated advice for WiredTiger (the default storage engine since 3.2), and reIndex won't even run on a replica set:

mongosh
db.users.reIndex()
db.runCommand({ compact: "users", dryRun: true, force: true })
Result
Uncaught MongoServerError[IllegalOperation]: reIndex is only allowed on a standalone mongod instance. Cannot reIndex 'ch19.users' while replication is active
{
  estimatedBytesFreed: 0,
  ok: 1,
  ... (cluster time fields trimmed)
}
What’s happening
  1. reIndex drops and rebuilds every index on the collection under an exclusive lock. Since 5.0 it's allowed only on standalone servers (the error above is from this replica set), and it's deprecated since 6.0. The docs say most users never need it. If one index looks corrupt or bloated on one member, drop and recreate that index, or resync the member.
  2. compact rewrites a collection's data and indexes to give unused space back to the operating system. Its dryRun option (8.0+) estimates how much it would free: 0 bytes here, because nothing had been deleted.
  3. WiredTiger reuses the space freed by deletes and updates for new writes, so routine compaction doesn't make anything faster. compact only makes sense after deleting a large share of a collection that won't grow back. The next example shows that case.
  4. Current versions of compact don't block reads and writes on the collection (old versions did), and since 8.0 autoCompact can run compaction continuously in the background. Run it on secondaries first, then step down the primary, and never during a backup.
AdvancedWhen compact does help: after a mass delete

Here a 500,000-document copy of the users loses 70% of its documents, and then gets compacted (size() forces a checkpoint and reports the on-disk size):

mongosh
for (let i = 0; i < 5; i++) db.users.aggregate([ { $project: { _id: 0 } }, { $merge: { into: "junk" } } ])
size()
db.junk.deleteMany({ score: { $lt: 700 } }).deletedCount
size()
db.runCommand({ compact: "junk", dryRun: true, force: true }).estimatedBytesFreed
db.runCommand({ compact: "junk", force: true }).bytesFreed
size()
Result
{ count: 500000, storageMB: 37.1 }
350475
{ count: 149525, storageMB: 74.7 }
38092800
38187008
{ count: 149525, storageMB: 38.3 }
What’s happening
  1. Five copies of the 100,000 users take 37.1 MB on disk (compressed).
  2. Deleting 350,475 documents doesn't shrink the file; it doubles it to 74.7 MB, because the deletes themselves are writes that need new pages before the old ones become free.
  3. The dry run estimates 38.1 MB reclaimable; the real compact frees 38.2 MB, bringing the file to 38.3 MB.
  4. Even then, the file is about as big as before the delete, for 30% of the documents. Without compact, WiredTiger would simply reuse that free space for new documents, which is why compaction is a one-off tool for "we deleted most of this and it won't come back", not a routine maintenance step.
AdvancedRolling index builds and other operational notes
  • Rolling builds: for very large indexes, take one secondary at a time out of the replica set, build the index on it as a standalone, put it back, then step down the primary and do it last. It avoids load on the whole set, but requires an election and lowers resilience during the procedure, so the docs reserve it for special cases.
  • Since 7.1 most index build errors are reported as soon as they're found during the collection scan (not at commit), a build stops if free disk falls below indexBuildMinAvailableDiskSpaceMB, and a secondary can ask the primary to abort a failing build instead of crashing.
  • Since 8.0, when an index build is committed, the write concern only waits for the commit to replicate; how many members must finish building is controlled by commitQuorum.
  • Sharded clusters: createIndex through mongos builds on every shard; make sure the shard key index exists before sharding.
  • Monitor a running build with db.currentOp({ "command.createIndexes": { $exists: true } }), and stop one with db.users.dropIndex(name) (which aborts an in-progress build).
  • Write cost is permanent: before adding an index, check $indexStats for ones you can remove.

SQL and MySQL written fresh; MongoDB built from Rohit’s “mongoDB interview” doc. Every query was run on MySQL 9.7, MongoDB 9.0, Redis 8.10 or PostgreSQL 18.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.