MongoDB Course — 03 · Indexes — Single, Compound, Multikey, Text, Geo, TTL, Wildcard, Hashed
An index is a B-tree (or specialised structure) on one or more fields that lets MongoDB find documents without scanning the whole collection. Indexes turn a 60-second query into a 5-millisecond one — and turn an undertuned cluster into a poorly-running mess if used wrong (too many, too wide, wrong order). This post covers every index type, the ESR rule (Equality, Sort, Range) for compound-index ordering, partial / sparse / TTL / wildcard / hashed variants, index intersection, covered queries, and the build options (background, unique, collation).
Series map: 00 Foundations → 01 CRUD → 02 Query Operators → 03 Indexes (this post) → 04 Query Optimization → 05 Aggregation Basics → … → 17 Drivers & Case Studies.
Learning Outcome
- Create single-field, compound, multikey, text, geospatial, hashed indexes.
- Apply the ESR rule to compound index ordering.
- Use partial, sparse, TTL, wildcard, unique indexes correctly.
- Recognise covered queries.
- Understand index intersection and when not to rely on it.
- List, hint, drop, hide indexes.
- Build indexes in production without downtime.
Chapter 1 — Index basics
Every MongoDB collection has at least one index — on _id (unique, auto-created, can't drop). All others are user-created.
1
2
3
4
5
db.users.createIndex({ email: 1 }) // ascending B-tree
db.users.createIndex({ created_at: -1 }) // descending
db.users.getIndexes() // list
db.users.dropIndex("email_1") // by name
Naming: auto-generated as <field>_<dir> joined by _. Use { name: "my_idx" } option for custom.
Cost trade-off
- Indexes speed reads that filter / sort / project on indexed fields.
- Indexes slow writes — every insert / update / delete must update every relevant index.
- Indexes use RAM — frequently accessed parts of indexes must fit in the WiredTiger cache for speed (Post 15).
- Indexes use disk — typically 10-30% of collection data size each.
Rule: index for the queries you actually run, drop indexes never used (Post 04 covers $indexStats to find unused).
Chapter 2 — Single-field index
The simplest:
1
2
3
4
5
db.users.createIndex({ email: 1 })
db.users.find({ email: "ada@x" }) // uses the index — O(log n)
db.users.find({ email: /^ad/ }) // uses the index (left-anchored regex)
db.users.find({ email: /ad/ }) // can't use the index (no left anchor)
Direction (1 vs -1) doesn't matter for a single-field index — the B-tree can be walked either way. It matters for compound indexes when sorting on multiple fields.
Chapter 3 — Compound index + the ESR rule
A compound index is on multiple fields:
1
2
db.orders.createIndex({ user_id: 1, status: 1, at: -1 })
The order of fields in the index matters enormously. The ESR rule:
E — Equality fields first.
S — Sort fields second.
R — Range fields last.
Why: B-trees can serve range scans only on the trailing indexed prefix once equality fields have been matched. Out-of-order indexes do partial work then full scans.
Example
Query: "Most recent paid orders for user 1, sorted desc by date."
1
2
db.orders.find({ user_id: 1, status: "paid" }).sort({ at: -1 })
Best index:
1
2
3
db.orders.createIndex({ user_id: 1, status: 1, at: -1 })
// ^^^^^^^^^^^ equality first ^^^^ sort/range last
The index serves:
find({ user_id: 1 })— uses prefix.find({ user_id: 1, status: "paid" })— uses prefix.find({ user_id: 1 }).sort({ status: 1 })—statuscomes right after the equality field, so the index order is already the sort order.find({ user_id: 1, status: "paid", at: { $gte: D } }).sort({ at: -1 })— full use.
Doesn't serve:
find({ status: "paid" })—user_idis the prefix; can't skip it.find({ user_id: 1 }).sort({ at: -1 })— the sort.statussits betweenuser_idandatand is not in the filter, so the index finds the rows but MongoDB must still sort them in memory. Addstatusto the filter, or create{ user_id: 1, at: -1 }.
Prefix rule
A compound index { a: 1, b: 1, c: 1 } can serve:
{ a }{ a, b }{ a, b, c }
But not:
{ b }alone —ais the required prefix.
Trick: if you frequently query by b alone, you need a separate index { b: 1 }. Don't try to make one mega-index do everything.
Direction matters for sort
Index { at: 1, user_id: 1 }:
.sort({ at: 1, user_id: 1 })— uses it directly..sort({ at: -1, user_id: -1 })— uses it in reverse scan (also OK)..sort({ at: 1, user_id: -1 })— can't use it (mixed direction).
Build the index to match your most common sort direction.
Chapter 4 — Multikey index (arrays)
When indexed field is an array, MongoDB creates one index entry per array element. This is called a multikey index — automatic, no special syntax.
1
2
3
4
db.users.createIndex({ tags: 1 })
db.users.find({ tags: "math" }) // matches if "math" in array
db.users.find({ tags: { $all: ["math", "eng"] } }) // can use the index
Limits:
- Only one array field in a compound multikey index.
{ tags: 1, scores: 1 }where both are arrays is forbidden (would explode entries quadratically). - Multikey indexes are larger than single-value indexes.
For arrays of sub-documents, use full-path field names:
1
2
db.orders.createIndex({ "items.p": 1 }) // multikey on items[].p
Chapter 5 — Text index
1
2
3
db.articles.createIndex({ title: "text", body: "text" })
db.articles.createIndex({ "$**": "text" }) // every string field — careful, big
One text index per collection. Use only when "real" search (Atlas Search, Lucene, ES) isn't available. Coverage of operators in Post 02 Chapter 6.
Chapter 6 — Geospatial index
1
2
db.places.createIndex({ location: "2dsphere" })
Required for $near, $geoWithin, $geoIntersects on GeoJSON points/polygons. Post 02 Chapter 8.
Chapter 7 — Hashed index
1
2
db.users.createIndex({ _id: "hashed" })
Used for:
- Sharding with a hashed shard key — distributes writes evenly (Post 11).
- Equality lookups on fields where you don't need range queries.
Doesn't support range queries ($gt, etc.). Don't combine with sort on the hashed field.
Chapter 8 — Partial index — index only some documents
1
2
3
4
5
db.users.createIndex(
{ email: 1 },
{ partialFilterExpression: { active: true } }
)
The index contains only docs where active: true. Queries that include active: true can use it; others can't.
Use cases:
- Index only "open" tickets / "active" users / non-deleted records — smaller index, faster builds.
- Conditional uniqueness (next).
Chapter 9 — Sparse index — index only docs that have the field
1
2
db.users.createIndex({ phone: 1 }, { sparse: true })
Skips documents missing the field entirely. Smaller index when the field is optional.
Partial vs sparse:
- Sparse — only checks field presence.
- Partial — checks arbitrary
partialFilterExpression.
Partial is the modern superset. New work: use partial.
Chapter 10 — Unique index
1
2
db.users.createIndex({ email: 1 }, { unique: true })
Attempts to insert a duplicate raise E11000 duplicate key error.
Unique + partial — conditional uniqueness
1
2
3
4
5
6
// emails must be unique among active users only
db.users.createIndex(
{ email: 1 },
{ unique: true, partialFilterExpression: { active: true } }
)
Common pattern: soft-deleted users may share an email with a new active user.
Unique + multikey
{ tags: 1 } unique applies per array element across documents — usually not what you want. Use a separate dedicated collection for tag uniqueness.
Chapter 11 — TTL index — auto-delete after N seconds
1
2
3
4
5
db.sessions.createIndex(
{ last_active_at: 1 },
{ expireAfterSeconds: 3600 } // expire 1 hour after last_active_at
)
A background thread deletes documents whose indexed date is older than expireAfterSeconds ago. Runs roughly every 60 s — TTL is approximate, not exact.
Field must be a Date (or array of Dates → uses earliest).
Common uses:
- Sessions, OTP codes, password-reset tokens.
- Short-term caches.
- Rate-limit counters.
To expire at a specific instant: store the desired expiry timestamp directly, set expireAfterSeconds: 0:
1
2
db.coupons.createIndex({ expires_at: 1 }, { expireAfterSeconds: 0 })
Watch out: TTL deletes generate write load + replication traffic — a large TTL cleanup spike can saturate a small instance.
Chapter 12 — Wildcard index — index unknown fields
1
2
3
4
db.docs.createIndex({ "$**": 1 }) // every field
db.docs.createIndex({ "meta.$**": 1 }) // every subfield of meta
db.docs.createIndex({ "$**": 1 }, { wildcardProjection: { name: 0 } }) // exclude
Useful when documents have heterogeneous / unknown fields and the app queries arbitrary attributes — common in multi-tenant SaaS where each tenant has custom fields.
Costs: big indexes, expensive writes. Reach for only when access patterns truly vary.
Chapter 13 — Covered queries
A query is covered when:
- Every queried field is in an index.
- Every projected field is in the same index.
- Query doesn't return
_idunless_idis in the index.
Covered queries return results straight from the index — no document fetch — extremely fast.
1
2
3
4
5
6
7
8
db.users.createIndex({ email: 1, name: 1 })
db.users.find(
{ email: "ada@x" },
{ email: 1, name: 1, _id: 0 } // no _id, only indexed fields
)
// covered query — no document fetch
explain() (Post 04) shows IXSCAN with no following FETCH stage when a query is covered.
Chapter 14 — Index intersection (use sparingly)
MongoDB can intersect two single-field indexes to satisfy a multi-field query, but it's usually slower than a single compound index that matches the query.
1
2
3
4
5
6
7
8
// Two indexes
db.orders.createIndex({ user_id: 1 })
db.orders.createIndex({ status: 1 })
// Query
db.orders.find({ user_id: 1, status: "paid" })
// May use intersection — typically slower than a compound { user_id: 1, status: 1 }
Rule: design compound indexes for the queries you run, not single-field per-column indexes.
Chapter 15 — Build options
Build in background (5.0+: always)
Index builds since 5.0 use a hybrid foreground/background protocol that doesn't block writes. No special flag needed. (Older versions used { background: true } — still accepted, no-op.)
Collation — case + locale-aware
1
2
3
4
5
6
7
db.users.createIndex(
{ name: 1 },
{ collation: { locale: "en", strength: 2 } } // case-insensitive
)
db.users.find({ name: "ada" }).collation({ locale: "en", strength: 2 })
Without matching collation on the query, the index is not used.
Hiding (test removal without dropping)
1
2
3
4
db.users.hideIndex("email_1") // index still maintained but not used by planner
db.users.unhideIndex("email_1")
db.users.dropIndex("email_1") // permanent
Safe pattern for "is this index needed?": hide for a week, observe query performance, drop if no regression.
Chapter 16 — Production rules of thumb
- Index every field used in a frequent filter, sort, or join. Not more.
- Compound indexes follow ESR.
- Sort direction in the index matches your most common sort.
- Use partial indexes to shrink indexes for filtered subsets (active users, open tickets).
- Use TTL for inherently temporary data.
- Index size + frequently-accessed entries should fit in RAM — Post 15.
- Drop unused indexes —
$indexStatsshows usage (Post 04). - Build new indexes in off-peak windows for large collections, even with hybrid background builds — backfill is I/O heavy.
- Don't index every field "just in case" — write amplification + RAM waste.
- Test with
explain()before shipping any new query — Post 04.
Practice Exercises
Exercise 1 — Create + verify
In the shop dataset, create an index on users.email and confirm with getIndexes().
Exercise 2 — ESR design
Given query find({ user_id, status: "paid" }).sort({ at: -1 }), design the compound index. Explain why each field is in its position.
Answer
{ user_id: 1, status: 1, at: -1 }. user_id and status are equality → first two. at is sort + range candidate → last. Direction -1 matches the desc sort.
Exercise 3 — Partial unique
Create an index that ensures email is unique among active users only.
Answer
1
2
3
4
5
db.users.createIndex(
{ email: 1 },
{ unique: true, partialFilterExpression: { active: true } }
)
Exercise 4 — TTL for sessions
Create a sessions collection where each document has a created_at Date and auto-expires after 1 hour.
Exercise 5 — Covered query
Create an index { email: 1, name: 1 }. Write a query that's covered (no document fetch). Use .explain('executionStats') to confirm totalDocsExamined: 0.
Exercise 6 — Hide-then-drop
Pick any non-essential index. Hide it. Wait a day. Inspect query performance + $indexStats. Drop it.
Cheat-sheet
| What | How |
|---|---|
| Create | db.c.createIndex({ f: 1 }) |
| List | db.c.getIndexes() |
| Drop | db.c.dropIndex("name_1") |
| Stats | db.c.aggregate([{ $indexStats: {} }]) |
| Compound | { a: 1, b: 1, c: -1 } |
| ESR rule | Equality → Sort → Range |
| Prefix rule | compound {a,b,c} serves {a}, {a,b}, {a,b,c} |
| Multikey | automatic on arrays; one array field per compound |
| Unique | { unique: true } |
| Partial | { partialFilterExpression: { active: true } } |
| Sparse | { sparse: true } (legacy; use partial) |
| TTL | { expireAfterSeconds: N } on Date field |
| Wildcard | { "$**": 1 } for unknown fields |
| Hashed | { _id: "hashed" } for sharding equality |
| Text | { field: "text" } — one per collection |
| Geo | { location: "2dsphere" } |
| Collation | { collation: { locale, strength } } |
| Hide | db.c.hideIndex("name_1") |
| Covered query | all fields in one index; no document fetch |
What's next
Post 04 covers query optimization — the explain('executionStats') output (IXSCAN vs COLLSCAN, totalKeysExamined vs nReturned, executionTimeMillis), the planner, hint(), plan cache, $indexStats, and the diagnostic flow for any "why is this query slow?".
References
Note: this post is in progress. Planned additions:
2dsphereindex quirks,clustered collections(5.3+), index build progress monitoring, rolling-index-build pattern in replica sets.










