MongoDBIntroduction to Indexes

Introduction to Indexes

An index is a data structure that speeds up query execution by maintaining a sorted reference to field values. Without indexes, MongoDB performs a collection scan (COLLSCAN) — reading every document in the collection. With an appropriate index, it performs an index scan (IXSCAN) — reading only the matching index entries and fetching just those documents.

How Indexes Work

MongoDB indexes use a B-tree data structure. The index stores a sorted list of field values alongside pointers to the documents containing those values. For a query like { age: { $gt: 25 } }, MongoDB uses the index to jump directly to the first value greater than 25 and reads forward — scanning only the relevant portion rather than the entire collection.

Note
Indexes speed up reads but slow down writes — every insert, update, and delete must also update all relevant indexes on that collection. This is the fundamental index trade-off: read performance vs. write overhead.
The Default _id Index

Every MongoDB collection automatically has a unique index on _id. This index is created when the collection is first created and cannot be dropped. It guarantees that every document in a collection has a unique identifier and that lookups by _id are always fast, regardless of collection size.

Creating a Single Field Index

createIndex() examples

JS
// Ascending index on the email field
db.users.createIndex({ email: 1 })

// Descending index on createdAt (useful for "newest first" queries)
db.users.createIndex({ createdAt: -1 })

// Unique index — prevents duplicate values
db.users.createIndex({ username: 1 }, { unique: true })

// Unique index with a custom name
db.users.createIndex(
  { email: 1 },
  { unique: true, name: "unique_email_idx" }
)

// Verify the index was created
db.users.getIndexes()
Index Options

Option

Description

Example

unique

Prevents duplicate values for the indexed field(s)

{ unique: true }

sparse

Only indexes documents where the field exists — skips documents missing the field

{ sparse: true }

background (deprecated)

Pre-4.2: build index without blocking the collection. Now all builds are non-blocking by default

{ background: true }

expireAfterSeconds

TTL index — MongoDB automatically deletes documents after this many seconds past the indexed date field

{ expireAfterSeconds: 3600 }

name

Assign a custom name to the index instead of the auto-generated one

{ name: "my_idx" }

partialFilterExpression

Only index documents matching a filter — reduces index size and write overhead

{ partialFilterExpression: { status: "active" } }

Listing and Describing Indexes

Index management

JS
// List all indexes on a collection
db.users.getIndexes()
// Returns array of index documents:
// [
//   { v: 2, key: { _id: 1 }, name: "_id_" },
//   { v: 2, key: { email: 1 }, name: "email_1", unique: true },
//   { v: 2, key: { createdAt: -1 }, name: "createdAt_-1" }
// ]

// Detailed index usage statistics (how often each index is used)
db.users.aggregate([{ $indexStats: {} }])
// Shows: name, key, accesses.ops (number of times used since last restart)

// Size of each index in bytes
db.users.stats().indexSizes
Dropping Indexes

Dropping indexes

JS
// Drop a specific index by its name
db.users.dropIndex("email_1")

// Drop a specific index by its key specification
db.users.dropIndex({ email: 1 })

// Drop ALL indexes except the mandatory _id index
db.users.dropIndexes()

// Drop multiple specific indexes by name (MongoDB 4.4+)
db.users.dropIndexes(["email_1", "createdAt_-1"])
Warning
dropIndexes() drops ALL indexes except _id. On a busy production collection, this can severely degrade query performance — potentially causing full collection scans on every query — until the indexes are rebuilt. Rebuild them immediately after, or drop only specific indexes by name.
Using explain() to Analyze Queries

explain() — query analysis

JS
// Basic explain — shows query plan (no actual execution)
db.users.find({ age: { $gt: 25 } }).explain()

// executionStats — actually runs the query and collects metrics
db.users.find({ age: { $gt: 25 } }).explain("executionStats")

// allPlansExecution — shows stats for all candidate plans considered
db.users.find({ age: { $gt: 25 } }).explain("allPlansExecution")

// Works with aggregation too
db.users.aggregate(
  [{ $match: { age: { $gt: 25 } } }, { $group: { _id: "$country" } }],
  { explain: true }
)

// Example explain() output for a COLLSCAN (no index):
// {
//   "queryPlanner": {
//     "winningPlan": {
//       "stage": "COLLSCAN",        // full collection scan
//       "filter": { "age": { "$gt": 25 } }
//     }
//   },
//   "executionStats": {
//     "nReturned": 47,
//     "totalDocsExamined": 100000,  // read 100k docs to find 47
//     "totalKeysExamined": 0,
//     "executionTimeMillis": 523
//   }
// }

// Example explain() output for an IXSCAN (with index on age):
// {
//   "queryPlanner": {
//     "winningPlan": {
//       "stage": "FETCH",
//       "inputStage": {
//         "stage": "IXSCAN",        // index scan
//         "keyPattern": { "age": 1 },
//         "indexName": "age_1"
//       }
//     }
//   },
//   "executionStats": {
//     "nReturned": 47,
//     "totalDocsExamined": 47,      // read exactly 47 docs
//     "totalKeysExamined": 47,
//     "executionTimeMillis": 2
//   }
// }
Reading explain() Output

Field

Meaning

stage: "COLLSCAN"

Full collection scan — no index used. Red flag on large collections

stage: "IXSCAN"

Index scan — an index was used. Generally what you want to see

stage: "FETCH"

Documents fetched from disk after index lookup — normal when projection includes non-indexed fields

stage: "PROJECTION_COVERED"

Query fully satisfied by the index — no document fetch needed (fastest possible)

nReturned

Number of documents returned to the client

totalDocsExamined

Number of documents read from disk. Should be close to nReturned for efficient queries

totalKeysExamined

Number of index entries scanned. Should be close to nReturned for selective indexes

executionTimeMillis

Total query execution time in milliseconds

The Performance Difference

Query without index

JS
// Collection: 100,000 user documents, no index on age
db.users.find({ age: { $gt: 25 } }).explain("executionStats")

// Result:
// {
//   "winningPlan": { "stage": "COLLSCAN" },
//   "executionStats": {
//     "nReturned": 47,
//     "totalDocsExamined": 100000,   // scanned every document
//     "totalKeysExamined": 0,
//     "executionTimeMillis": 523     // 523ms
//   }
// }

Same query with index

JS
// After: db.users.createIndex({ age: 1 })
db.users.find({ age: { $gt: 25 } }).explain("executionStats")

// Result:
// {
//   "winningPlan": {
//     "stage": "FETCH",
//     "inputStage": { "stage": "IXSCAN", "indexName": "age_1" }
//   },
//   "executionStats": {
//     "nReturned": 47,
//     "totalDocsExamined": 47,       // only fetched 47 documents
//     "totalKeysExamined": 47,       // scanned 47 index entries
//     "executionTimeMillis": 2       // 2ms — 260x faster
//   }
// }
Index Sizes

JS
// View the memory footprint of all indexes on a collection
db.users.stats().indexSizes
// {
//   "_id_":         589824,    // ~576 KB
//   "email_1":      2097152,   // ~2 MB
//   "createdAt_-1": 1572864    // ~1.5 MB
// }

// Total index size across all indexes
db.users.stats().totalIndexSize

// Server-level: working set and cache stats
db.serverStatus().wiredTiger.cache
Tip
A query without an index on a large collection (millions of documents) is one of the most common causes of slow MongoDB performance in production. Make it a habit to run .explain("executionStats") on any new query and confirm you see IXSCAN, not COLLSCAN. Add indexes for any field used in find(), sort(), or aggregation $match stages on large collections.