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.
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
// 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
// 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().indexSizesDropping Indexes
Dropping indexes
// 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"])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
// 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
// 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
// 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
// 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.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.