Index Fundamentals
An index is a separate, ordered data structure that lets MongoDB find documents without scanning the whole collection. Every performance-sensitive query in MongoDB ultimately comes down to one question: is there an index that serves this query?
How B-Tree Indexes Work
MongoDB's default index type is a B-tree (specifically a B+ tree variant). Conceptually, it's a sorted list of (field value → document location) pairs, organized as a balanced tree so lookups, range scans, and inserts all stay fast even as the collection grows to millions of documents.
Each entry in the tree is a key (the indexed field's value, or values for a compound index) paired with a pointer to the full document.
Because entries are stored sorted, a query for an exact value, a range, or a sort order can all be satisfied by walking the tree directly — no need to inspect documents that don't match.
Without an index, MongoDB must perform a collection scan (COLLSCAN) — reading every document in the collection to check it against the filter.
The Default _id Index
Every collection automatically gets a unique B-tree index on _id the moment it's created — you cannot drop it, and you never need to create it yourself.
The default index
db.orders.getIndexes()
// [ { v: 2, key: { _id: 1 }, name: '_id_' } ] — always presentCreating, Listing, and Dropping Indexes
createIndex / getIndexes / dropIndex
db.orders.createIndex({ status: 1 }) // ascending single-field index
db.orders.createIndex({ status: 1, createdAt: -1 }) // compound index
db.orders.getIndexes() // list every index on the collection
db.orders.dropIndex("status_1") // drop by name
db.orders.dropIndex({ status: 1 }) // or drop by key patternIndex Build Impact
Building an index on an existing, populated collection requires scanning and sorting every document — on a large collection, this can take significant time and I/O.
Build Method | Impact |
|---|---|
Foreground (older versions / default in some contexts) | Historically blocked other operations on the collection for the build's duration |
Rolling / online build (current default) | Builds without holding an exclusive lock for the whole duration — reads/writes continue, though the build itself consumes CPU/IO |
currentOp() to watch build progress on big collections.When Indexes Help
Equality and range filters on the indexed field(s) —
find({ status: "shipped" }),find({ price: { $gte: 10 } }).Sorting — a sort that matches an index's key order avoids an in-memory sort entirely.
Covered queries — when every field the query needs (filter + projection) is present in the index itself, MongoDB never touches the underlying document at all.
Uniqueness enforcement — a unique index rejects duplicate values at write time.
When Indexes Hurt
Write overhead — every insert/update/delete that touches an indexed field must also update every index containing that field. More indexes = slower writes.
Low-selectivity fields — an index on a boolean or a field with only 2-3 distinct values rarely helps; the optimizer may still choose a collection scan because the index doesn't narrow things down much.
Storage and RAM — every index consumes disk space and, ideally, RAM (see below) — indexes you don't query by are pure overhead.
Unused indexes — an index nothing queries against still costs write overhead with zero read benefit. Periodically audit with
$indexStatsand drop unused ones.
Index Size and RAM
MongoDB performs best when the working set — the indexes and frequently-accessed documents — fits in RAM. An index that doesn't fit in memory forces disk reads on every lookup, erasing much of its benefit.
Checking index size
db.orders.stats().indexSizes
// { _id_: 2097152, status_1: 1048576, status_1_createdAt_-1: 3145728 } (bytes)
db.orders.totalIndexSize() // sum across all indexes, in bytesCovered Queries
A query is covered when the index alone contains every field the query needs — both the filter fields and any projected fields. MongoDB can answer it directly from the index, without a FETCH stage to load the full document at all.
A covered query
db.orders.createIndex({ status: 1, total: 1 })
// filter on status, project total — BOTH fields are in the index, _id excluded
db.orders.find({ status: "shipped" }, { total: 1, _id: 0 }){
winningPlan: {
stage: 'PROJECTION_COVERED', // answered entirely from the index — no FETCH
inputStage: { stage: 'IXSCAN', indexName: 'status_1_total_1' }
},
executionStats: {
totalDocsExamined: 0 // the telltale sign of a covered query
}
}totalDocsExamined: 0 in explain() is the signature of a fully covered query — the fastest possible read MongoDB can serve, since it never touches the document storage at all.Indexes are B-tree structures that let MongoDB avoid scanning the whole collection.
_idalways has a default unique index — every other index is opt-in viacreateIndex().Indexes speed up reads but add overhead to every write that touches an indexed field.
A covered query (
totalDocsExamined: 0) is the fastest possible read — the index alone answers it.Use
explain()and$indexStatsto verify an index is actually helping before keeping it.