MongoDBIndexes Deep Dive

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

JS
db.orders.getIndexes()
// [ { v: 2, key: { _id: 1 }, name: '_id_' } ]  — always present
Creating, Listing, and Dropping Indexes

createIndex / getIndexes / dropIndex

JS
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 pattern
Index 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

Warning
Building a large index still consumes significant CPU, memory, and disk I/O even when it doesn't fully block other operations. Build large indexes during lower-traffic windows, and monitor 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 $indexStats and 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

JS
db.orders.stats().indexSizes
// { _id_: 2097152, status_1: 1048576, status_1_createdAt_-1: 3145728 }  (bytes)

db.orders.totalIndexSize()   // sum across all indexes, in bytes
Tip
Keep an eye on total index size relative to available RAM, especially as a collection grows. Dropping unused or redundant indexes (see the Compound Index page for how a compound index can make several single-field indexes redundant) is one of the highest-leverage ways to shrink your working set.
Covered 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

JS
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
  }
}
Note
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.

  • _id always has a default unique index — every other index is opt-in via createIndex().

  • 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 $indexStats to verify an index is actually helping before keeping it.