Skip to main content
Published / updated

Indexes and Performance

Before you start

You need: data modelling (Article 05).

Time: about 50 minutes, plus the practice.

Learning objective

Add indexes that measurably change a query plan, and diagnose a slow query from its explain output rather than by guessing.

Topics

  • Why index
  • Creating indexes
  • Compound indexes and field order
  • Covered queries
  • Index types
  • Reading an explain plan
  • Index costs
  • Finding slow queries
  • Aggregation basics

Why index

Without an index, MongoDB reads every document to answer a query — a collection scan.

db.students.find({ rollNumber: "NCA-2024-0012" })

On 400 documents that is instant. On 400,000 it reads all of them, and the query gets slower every month as data grows.

An index is a sorted structure mapping values to documents, so MongoDB jumps directly to the matches. The same trade-off as SQL Server: faster reads, slower writes, more storage.

Every collection has an index on _id, created automatically and undroppable.

Creating indexes

db.students.createIndex({ rollNumber: 1 }) // ascending
db.students.createIndex({ createdAt: -1 }) // descending
db.students.createIndex({ schoolId: 1, className: 1, section: 1 })
db.students.createIndex({ schoolId: 1, rollNumber: 1 }, { unique: true })

db.students.getIndexes()
db.students.dropIndex("rollNumber_1")
db.students.totalIndexSize()

Direction is irrelevant for a single-field index — MongoDB can walk it either way. It matters for compound indexes used in sorts.

db.students.createIndex({ schoolId: 1, className: 1 }, { name: "idx_school_class" })

Naming an index makes it identifiable in explain output and droppable by a stable name.

Building an index on a large collection blocks writes on older versions; modern MongoDB builds in the background by default. On a production collection, still schedule it.

Compound indexes and field order

db.students.createIndex({ schoolId: 1, className: 1, section: 1 })

Field order decides which queries the index serves. This index supports:

db.students.find({ schoolId: 1 })
db.students.find({ schoolId: 1, className: "10th" })
db.students.find({ schoolId: 1, className: "10th", section: "A" })

and does not support:

db.students.find({ className: "10th" }) // schoolId missing
db.students.find({ section: "A" })
db.students.find({ className: "10th", section: "A" })

An index can only be used from the left. Think of a phone book sorted by surname then first name: it cannot find everyone called "Ravi".

This is the prefix rule, and it is the single most important thing about compound indexes.

The ESR rule

Order fields as Equality, Sort, Range.

db.students.find({ schoolId: 1, className: "10th", marks: { $gt: 80 } })
.sort({ name: 1 })
db.students.createIndex({
schoolId: 1, // Equality
className: 1, // Equality
name: 1, // Sort
marks: 1 // Range
})

Equality fields narrow to a contiguous section of the index. The sort field then reads in order with no in-memory sort. The range field filters last.

Putting the range before the sort forces an in-memory sort, because the matching entries are no longer contiguous in sort order.

In a multi-tenant system, schoolId is always the leading field, because every query filters on it.

Fewer, broader indexes

// Redundant — the first is a prefix of the second
db.students.createIndex({ schoolId: 1 })
db.students.createIndex({ schoolId: 1, className: 1 })

The second serves every query the first does. Drop the first.

One well-ordered compound index usually replaces three single-field ones. MongoDB can intersect indexes but rarely chooses to, so do not rely on it.

Covered queries

db.students.createIndex({ schoolId: 1, className: 1, name: 1, rollNumber: 1 })

db.students.find(
{ schoolId: 1, className: "10th" },
{ name: 1, rollNumber: 1, _id: 0 }
)

Every field needed is in the index, so MongoDB never reads a document. explain shows totalDocsExamined: 0 — the fastest possible query.

Two requirements: every queried and projected field must be in the index, and _id must be excluded unless it is in the index.

Covered queries suit list pages, which need four or five fields from documents holding forty.

Index types

// Multikey — automatic on any array field
db.students.createIndex({ subjects: 1 })
db.students.find({ subjects: "Mathematics" })

MongoDB indexes each element separately. A compound index may contain only one array field — two would produce a combinatorial explosion, and MongoDB refuses.

// Text — word search
db.students.createIndex({ name: "text", rollNumber: "text" })
db.students.find({ $text: { $search: "Ravi Kumar" } })

One text index per collection, covering as many fields as needed. It tokenises words, so it does not do partial-word matching — right for a search box, wrong for autocomplete.

// Partial — index only some documents
db.students.createIndex(
{ schoolId: 1, className: 1 },
{ partialFilterExpression: { status: "Active" } }
)

Smaller and faster when most queries want active students. The query must include the same predicate for the index to be used — { schoolId: 1, className: "10th" } alone will not use it.

// Sparse — skip documents missing the field
db.students.createIndex({ email: 1 }, { sparse: true, unique: true })

A plain unique index treats missing fields as equal, so only one document may omit email. sparse or a partial filter fixes that.

// TTL — delete documents automatically
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 })

Right for sessions, tokens and temporary data. The background remover runs about every 60 seconds, so deletion is not immediate.

// Case-insensitive, via a collation
db.students.createIndex({ name: 1 }, { collation: { locale: "en", strength: 2 } })
db.students.find({ name: "ravi kumar" }).collation({ locale: "en", strength: 2 })

The query must specify the same collation or the index is not used — a common reason a case-insensitive index appears to do nothing.

Reading an explain plan

db.students.find({ schoolId: 1, className: "10th" }).explain("executionStats")
{
executionStats: {
nReturned: 42,
executionTimeMillis: 2,
totalKeysExamined: 42,
totalDocsExamined: 42,
executionStages: { stage: "FETCH", inputStage: { stage: "IXSCAN" } }
}
}
FieldRead it as
stage: COLLSCANNo index used
stage: IXSCANIndex used
stage: FETCHDocuments read after the index
stage: SORTIn-memory sort — needs an index
nReturnedDocuments returned
totalKeysExaminedIndex entries read
totalDocsExaminedDocuments read

Three numbers tell you everything:

PatternMeaning
docsExamined: 0Covered query — ideal
docsExamined ≈ nReturnedGood index
docsExamined ≫ nReturnedIndex not selective enough
COLLSCAN with a large collectionMissing index
SORT stage presentSort not served by an index

Reading 50,000 documents to return 20 is a partial index — the equality fields matched but the rest was filtered after fetching.

db.students.find({ schoolId: 1 })
.sort({ name: 1 })
.explain("executionStats")

A SORT stage means MongoDB sorted in memory. That fails outright above 100 MB:

Sort exceeded memory limit of 104857600 bytes

The fix is an index covering the sort, not allowDiskUse — which makes it slow rather than failing.

db.students.find({ schoolId: 1, className: "10th" }).hint({ schoolId: 1, className: 1 })

hint forces an index. Useful for testing which is better; avoid it in application code — it prevents the optimiser adapting as data changes.

Index costs

Indexes are not free.

Writes slow down. Every insert updates every index. Ten indexes means eleven writes per insert.

Storage grows. A compound index on four fields over a million documents is substantial.

Memory matters most. Indexes work well when they fit in RAM. Once the working set exceeds memory, every query hits disk and performance falls off a cliff.

db.students.aggregate([{ $indexStats: {} }])
{ name: "idx_school_class", accesses: { ops: 48291, since: ISODate("...") } }
{ name: "email_1", accesses: { ops: 0, since: ISODate("...") } }

ops: 0 over a representative period means the index only costs. Drop it.

Counters reset when the server restarts, so judge them over a real period — not ten minutes after a deployment.

Do not index every field. Index the queries you actually run, verified with explain.

Finding slow queries

db.setProfilingLevel(1, { slowms: 100 })

db.system.profile.find().sort({ ts: -1 }).limit(10).pretty()

db.system.profile.find({ millis: { $gt: 500 } })
.sort({ millis: -1 })
.limit(10)
LevelRecords
0Nothing
1Operations slower than slowms
2Everything — development only

Level 1 with a sensible threshold is the production setting. Level 2 writes a profile document per operation and is itself a performance problem.

db.system.profile.aggregate([
{ $group: { _id: "$command.filter", count: { $sum: 1 }, avgMillis: { $avg: "$millis" } } },
{ $sort: { count: -1 } },
{ $limit: 10 }
])

A query taking 50 ms and running 10,000 times a minute costs more than one taking 2 seconds and running hourly. Sort by total time, not by duration.

db.currentOp({ "secs_running": { $gt: 5 } })
db.killOp(opid)

For a query blocking everything right now.

Atlas provides the Performance Advisor, which suggests indexes from real traffic. Treat its suggestions as candidates, not instructions — applying every one produces overlapping indexes that cripple writes.

Aggregation basics

For reports, find is not enough.

db.examResults.aggregate([
{ $match: { schoolId: 1, examId: ObjectId("e1"), isAbsent: false } },
{ $group: {
_id: "$className",
count: { $sum: 1 },
averageMarks: { $avg: "$marks" },
highestMarks: { $max: "$marks" }
}},
{ $sort: { averageMarks: -1 } }
])

A pipeline: each stage transforms the documents and passes them on.

StageDoes
$matchFilter — put it first
$groupAggregate
$projectReshape
$sortOrder
$limit / $skipPage
$lookupJoin another collection
$unwindOne document per array element
$countCount

$match first is the most important rule. It is the only stage that can use an index, and only while it is at the start of the pipeline. Filtering after a $group means grouping every document and discarding most of the result.

// Wrong — groups everything, then filters
db.examResults.aggregate([
{ $group: { _id: "$className", avg: { $avg: "$marks" } } },
{ $match: { _id: "10th" } }
])

// Right
db.examResults.aggregate([
{ $match: { className: "10th" } },
{ $group: { _id: "$className", avg: { $avg: "$marks" } } }
])
db.students.aggregate([
{ $match: { schoolId: 1, status: "Active" } },
{ $lookup: {
from: "examResults",
localField: "_id",
foreignField: "studentId",
as: "results"
}},
{ $project: {
name: 1,
rollNumber: 1,
resultCount: { $size: "$results" },
averageMarks: { $avg: "$results.marks" }
}}
])

$lookup is a left outer join and it is expensive — it runs a query per input document unless the foreign field is indexed. Index studentId on examResults, or the pipeline scans that collection once per student.

That cost is exactly why the previous article duplicated studentName onto result documents: the report reads one collection with no lookup at all.

{ $group: {
_id: "$className",
total: { $sum: 1 },
absent: { $sum: { $cond: [{ $eq: ["$isAbsent", true] }, 1, 0] } },
passed: { $sum: { $cond: [{ $gte: ["$marks", 35] }, 1, 0] } }
}}

$cond gives conditional counts in one pass — the equivalent of SUM(CASE WHEN ...).

db.examResults.aggregate(pipeline).explain("executionStats")

Aggregations explain too. Check the first stage uses an index.

Diagnosing a slow query

Work in this order.

1. explain("executionStats"). COLLSCAN means no index. A SORT stage means an in-memory sort.

2. Compare totalDocsExamined with nReturned. A large gap means the index is not selective enough.

3. Check the prefix rule. An index on { schoolId, className } does nothing for a query on className alone.

4. Check for a SORT stage. Add the sort field to the index, in ESR order.

5. Check the aggregation's first stage. $match must be first.

6. Check $lookup foreign fields are indexed.

7. Check the working set fits in memory. A sudden cliff as data grows is usually this.

SymptomCause
COLLSCANNo usable index
Index exists but unusedPrefix rule, type mismatch, or missing collation
SORT stageSort not covered by an index
Sort memory errorSame, on a large result
docsExamined ≫ nReturnedIndex too broad
Slow aggregation$match not first, or an unindexed $lookup
Writes slowing over timeToo many indexes
Sudden cliffWorking set exceeded RAM

A type mismatch also prevents index use, and it is easy to miss: a query passing a string against a numeric field matches nothing and reports a fast COLLSCAN — fast because it found nothing.

Errors you will hit

What you seeCauseFix
COLLSCAN in the explain planNo usable indexCreate one matching the query
Index exists but is not usedField order wrong for the queryCompound index order matters
Writes got slowerEvery index is maintained on writeIndex only what you query
A sort still scansSort field not in the indexInclude it
E11000 on a unique index in a multi-tenant collectionIndex not compound with the tenant{ schoolId: 1, rollNumber: 1 }, unique

Read explain() before and after adding an index. COLLSCAN to IXSCAN is the proof it did something.

Common mistakes

  • No index on a frequently queried field
  • Indexing every field
  • Wrong field order in a compound index
  • Ignoring the prefix rule
  • Range before sort, forcing an in-memory sort
  • The tenant field not leading in a multi-tenant system
  • A redundant index that is a prefix of another
  • hint in application code
  • A partial index whose predicate the query omits
  • A collation index queried without the collation
  • Two array fields in one compound index
  • $match not first in an aggregation
  • $lookup on an unindexed foreign field
  • Profiling level 2 in production
  • Judging index usage over a few minutes
  • Never running explain

Practice

The course exercise is create and explain a basic index.

  1. Insert 100,000 students with varied schools, classes and marks.
  2. Query by rollNumber with no index. Run explain and record the stage, totalDocsExamined and time.
  3. Add the index and re-run. Compare all three.
  4. Create { schoolId: 1, className: 1, section: 1 }. Test queries on schoolId, schoolId + className, className alone, and section alone. Record which use the index.
  5. Explain the prefix rule from what you observed.
  6. Sort by name with no covering index over 200,000 documents. Record the SORT stage, then the memory-limit error.
  7. Add an index covering the sort and confirm the stage disappears.
  8. Build a query with equality, sort and range. Order the index ESR, then range-before-sort, and compare the plans.
  9. Create an index covering a four-field projection with _id: 0. Confirm totalDocsExamined: 0.
  10. Add _id: 1 to the projection and confirm the coverage is lost.
  11. Create a partial index on status: "Active". Query with and without the predicate and compare.
  12. Create a unique index on email with two documents missing it. Record the failure, then use sparse.
  13. Create a collation index and query with and without the collation.
  14. Create ten indexes. Time 10,000 inserts, drop them, and repeat.
  15. Run $indexStats after exercising the collection and find an index with ops: 0.
  16. Enable profiling at level 1 with slowms: 50. Run a slow query and find it in system.profile.
  17. Write a class-average aggregation with $match last, then first. Compare executionTimeMillis.
  18. Add a $lookup on an unindexed studentId, then index it and compare.

Exercises 4, 8 and 17 are the three that most change how you write queries.

You can now

  • Create indexes that measurably change a query plan
  • Read explain() and tell COLLSCAN from IXSCAN
  • Order compound index fields correctly
  • Say why every index slows writes
  • Make a unique index tenant-safe

Review questions

  1. What is the prefix rule, and what does it mean for compound index design?
  2. What does the ESR order optimise, and what happens if range comes before sort?
  3. What does totalDocsExamined: 0 indicate?
  4. Why must $match be the first stage of an aggregation?

Next: Application integration