RizTech Academy logo
RizTech Academy
Document Databases in DepthLesson 5 of 730 min

Indexes, and why the same rules apply

Here is the good news: almost everything you learned about indexes in the SQL performance module applies to MongoDB unchanged. MongoDB uses B-trees, the leftmost-prefix rule holds, selectivity decides whether an index is used, and writes pay for every index. If module 7 landed, this lesson is mostly confirming that your knowledge transfers, plus the few MongoDB-specific kinds.

The same rules, demonstrated

On a 50,000-document collection, a query with no index scans everything; with one, it touches only the matches:

member_id = 42, no index :  COLLSCAN,  examined 50000,  returned 50
member_id = 42, indexed  :  IXSCAN,    examined 50,     returned 50

That is the SQL story exactly — COLLSCAN is a sequential scan, IXSCAN is an index scan, and the win is examining 50 documents instead of 50,000.

db.big.createIndex({ member_id: 1 })       // 1 ascending, -1 descending

The leftmost-prefix rule is identical. With a compound index { member_id: 1, tag: 1 }:

{ member_id: 42 }              →  IXSCAN   (prefix — uses it)
{ member_id: 42, tag: "tag7" } →  IXSCAN   (full — uses it)
{ tag: "tag7" }                →  COLLSCAN (not a prefix — cannot use it)

Same reason as before: the index is sorted by member_id first, so tag-only has no contiguous range. The column-ordering rules from module 7 — equality before range, then higher cardinality, then match your sort — carry over word for word. Mongo calls this ESR (Equality, Sort, Range), which is the same idea named for the pipeline.

Selectivity still decides. An index on a field where one value covers most documents will be ignored for that value, because a scan is cheaper — exactly as in PostgreSQL.

Reading the plan. db.coll.find(q).explain("executionStats") is EXPLAIN ANALYZE. The fields to read: the winning plan's stage (IXSCAN or COLLSCAN), totalDocsExamined, and nReturned. The number to watch is the ratio of examined to returned — the equivalent of buffers-per-row. Examined ≈ returned is a good index; examined far larger than returned means the index is not selective or not being used.

Covered queries — same as PostgreSQL's index-only scan

If the index contains every field the query needs — filter and projection — MongoDB answers from the index and never touches the document:

db.big.find({ member_id: 42 }, { _id: 0, member_id: 1 }).explain("executionStats")
// → totalDocsExamined: 0

totalDocsExamined: 0 is the proof — the Heap Fetches: 0 of the SQL world. You must exclude _id (it is not in the index unless you put it there) and project only indexed fields. Same technique, same payoff.

The MongoDB-specific kinds

Four index types the document model needs that SQL does not.

Multikey — an index on an array field

Index an array and MongoDB indexes every element, so an array-contains query uses it:

db.books.createIndex({ categories: 1 })
db.books.find({ categories: "fiction" })     // IXSCAN, isMultiKey: true

The plan reports isMultiKey: true. This is what makes array queries fast, and it is automatic — you do not ask for a multikey index, you get one by indexing an array field. The one restriction: a compound index can include at most one array field, because indexing two arrays together would multiply into every combination of their elements.

db.books.createIndex({ title: "text" })
db.books.find({ $text: { $search: "monsoon" } })     // → Monsoon Physics, The Long Monsoon

Word-level search with stemming — "monsoon" matches "Monsoon". This is MongoDB's equivalent of PostgreSQL's to_tsvector/GIN. It is fine for basic search; for anything serious you reach for a real search engine, which is module 12. A collection may have only one text index, though it can span several fields.

Partial — index only some documents

The same idea and the same payoff as PostgreSQL's partial index:

db.big.createIndex({ amount: 1 }, { partialFilterExpression: { city: "Pune" } })
{ amount: 100, city: "Pune" }  →  IXSCAN   (query implies the condition)
{ amount: 100 }                →  COLLSCAN (does not imply it — cannot use the partial index)

Smaller index, no write cost for excluded documents, and — exactly as in SQL — the query must imply the partial filter or the index is not eligible. MongoDB's older sparse index (index only documents where the field exists) is a special case; prefer partial, which is more general.

TTL — a partial index that deletes

One MongoDB has that SQL does not, and it is genuinely handy:

db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 })

A background task deletes documents once the indexed date is older than the expiry. Perfect for sessions, carts, verification tokens — anything with a natural lifespan. It is the housekeeping you would otherwise write a cron job for, and it previews the expiry ideas in the Redis module.

Unique — a constraint, as in SQL

db.members.createIndex({ email: 1 }, { unique: true })

Enforces uniqueness — but mind the null-matches-missing rule from the querying lesson: several documents missing the field all count as one null and collide. Combine unique with a partial filter to allow the missing ones — the document equivalent of a partial unique index.

What indexes cost — identical to SQL

  • Writes get slower. Every index is maintained on every insert, update of an indexed field, and delete — the same ~8× territory as module 7.
  • They take space. On the test collection the compound index was larger than the single-field one; you can read the sizes from db.coll.stats().indexSizes.
  • Memory. MongoDB wants the working set of indexes in RAM (its WiredTiger cache); indexes you do not use evict pages you do.

So the discipline is the same: index for your actual queries, and drop what is unused. MongoDB tracks usage — db.coll.aggregate([{ $indexStats: {} }]) gives an accesses.ops count per index, the equivalent of pg_stat_user_indexes, and an index with zero ops after real traffic is a candidate to drop.

Build indexes without blocking

Since MongoDB 4.2, createIndex builds in the background by default, holding only brief locks — the equivalent of CREATE INDEX CONCURRENTLY, and on by default rather than opt-in. On a large production collection it is still work; do it in a quiet window and watch it, but you do not have the "blocks all writes for minutes" hazard that a naive SQL CREATE INDEX has.

Check your work

How much of the SQL index knowledge transfers. Almost all — B-trees, leftmost prefix, selectivity, write cost.

COLLSCAN versus IXSCAN. Sequential scan versus index scan — measured 50,000 versus 50 documents examined.

How to create an index and what 1/-1 mean. createIndex({ field: 1 }); ascending and descending.

Why a tag-only query cannot use { member_id, tag }. Same as SQL — not a leftmost prefix, so no contiguous range.

What ESR stands for. Equality, Sort, Range — MongoDB's name for the column-ordering rule.

What to read in .explain("executionStats"). The stage, totalDocsExamined, nReturned — and especially the examined-to-returned ratio.

What proves a covered query. totalDocsExamined: 0, with _id excluded and only indexed fields projected.

What a multikey index is. An automatic index on every element of an array field; at most one array field per compound index.

MongoDB's text-search index. { field: "text" }, one per collection, with stemming — the to_tsvector equivalent.

What a partial index requires. The query must imply its partialFilterExpression.

What a TTL index does. Deletes documents once the indexed date passes the expiry — sessions, tokens, carts.

The unique-index gotcha. Missing fields all count as one null and collide; use a partial unique index.

How to find unused indexes. $indexStats and its accesses.ops count.

Why index builds do not block by default. Since 4.2 they build in the background — like CONCURRENTLY, but the default.

Practice

  1. Load 50,000 documents. explain a filter with no index and read COLLSCAN and totalDocsExamined.
  2. Add the index and re-run. Compare examined and returned.
  3. Build a compound index and test all three prefix cases from the table above.
  4. Reorder the two fields and re-test. Apply the ESR rule to predict which queries win.
  5. Make a covered query with a matching projection and confirm totalDocsExamined: 0.
  6. Index an array field, query it with a scalar, and confirm isMultiKey: true in the plan.
  7. Try to build a compound index on two array fields and read the error.
  8. Build a text index and search for a word in a different case. Confirm stemming.
  9. Build a partial index and confirm it is used only when the query implies its condition.
  10. Build a unique index on a field some documents lack. Insert two such documents and watch them collide. Fix it with a partial unique index.
  11. Build a TTL index with a short expiry, insert a document, and confirm it disappears within a minute or two.
  12. Run $indexStats, generate some queries, and find an index with zero accesses.ops.
  13. Read db.coll.stats().indexSizes and compare a single-field with a compound index.

Official documentation

Next: transactions, write concerns, and what you give up.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship