Indexes are the single most important performance lever in MongoDB. Without them, every query scans the entire collection. This guide covers index types, creation strategies, and how to diagnose query performance with explain().

How Indexes Work

MongoDB uses B-tree indexes (WiredTiger storage engine). Each index entry maps an indexed field value to the document’s storage location.

  Query without index:  scan 1,000,000 docs → slow
Query with index:    scan 50 index entries → fast
  

Trade-off: indexes speed reads but slow writes (each insert/update must update index entries).

Creating Indexes

  // Single field, ascending
db.products.createIndex({ category: 1 })

// Descending (equivalent for single-field equality queries)
db.products.createIndex({ createdAt: -1 })

// Compound index — field order matters
db.orders.createIndex({ userId: 1, createdAt: -1 })

// Unique index
db.users.createIndex({ email: 1 }, { unique: true })

// Background build (default in 4.2+ — non-blocking)
db.logs.createIndex({ timestamp: -1 })
  

Index Types

Single-Field Index

  db.products.createIndex({ sku: 1 })
// Supports: db.products.find({ sku: "WIDGET-01" })
  

Compound Index — ESR Rule

Order fields by Equality → Sort → Range:

  // Query: find({ status: "active" }).sort({ createdAt: -1 }).limit(20)
db.orders.createIndex({ status: 1, createdAt: -1 })

// Query: find({ userId: X, createdAt: { $gte: date } })
db.orders.createIndex({ userId: 1, createdAt: -1 })
  

Compound index { a: 1, b: 1, c: 1 } supports queries on:

  • { a }
  • { a, b }
  • { a, b, c }

But NOT { b } or { c } alone (left-prefix rule).

Multikey Index (Arrays)

  db.articles.createIndex({ tags: 1 })
// Supports: db.articles.find({ tags: "mongodb" })
  

Each array element gets an index entry. Compound multikey indexes have restrictions — only one array field per compound index.

Text Index

  db.articles.createIndex({ title: "text", body: "text" })
db.articles.find({ $text: { $search: "mongodb performance" } })
  

One text index per collection. Use Atlas Search for advanced full-text requirements.

Geospatial Index

  db.stores.createIndex({ location: "2dsphere" })

db.stores.find({
  location: {
    $near: {
      $geometry: { type: "Point", coordinates: [-122.33, 47.61] },
      $maxDistance: 5000  // meters
    }
  }
})
  

Hashed Index (Sharding)

  db.events.createIndex({ userId: "hashed" })
// Used as shard key for even distribution
  

Partial Index

Index only documents matching a filter — smaller, faster:

  db.orders.createIndex(
  { userId: 1, createdAt: -1 },
  { partialFilterExpression: { status: "active" } }
)
// Only indexes active orders — ideal when most queries filter by status
  

TTL Index

Automatic document expiration:

  db.sessions.createIndex(
  { createdAt: 1 },
  { expireAfterSeconds: 3600 }  // delete after 1 hour
)
  

Wildcard Index

Index all fields or a path subtree:

  db.events.createIndex({ "metadata.$**": 1 })
// Supports queries on any field under metadata
  

Use sparingly — can grow large with dynamic schemas.

Explain Plans

  const plan = db.orders.find({ userId: ObjectId("...") })
  .sort({ createdAt: -1 })
  .explain("executionStats")

// Key metrics:
// executionStats.executionStages.stage — "IXSCAN" (good) vs "COLLSCAN" (bad)
// executionStats.totalDocsExamined — should be close to nReturned
// executionStats.totalKeysExamined
// executionStats.executionTimeMillis
  

Reading explain Output

Stage Meaning
COLLSCAN Full collection scan — missing or wrong index
IXSCAN Index scan — good
FETCH Retrieve documents after index lookup
SORT In-memory sort — add sort fields to index

Ideal plan: IXSCANFETCH with totalDocsExamined ≈ nReturned.

Index Management

  // List indexes
db.orders.getIndexes()

// Drop unused index
db.orders.dropIndex("status_1_createdAt_-1")

// Hide index (test impact before dropping — Enterprise/Atlas)
db.orders.hideIndex("legacy_index_1")

// Index stats (usage since server start)
db.orders.aggregate([{ $indexStats: {} }])
  

Index Build in Production

  • Index builds on populated collections consume CPU and I/O
  • MongoDB 4.2+ builds indexes without blocking reads/writes (mostly)
  • On large collections, build during low-traffic windows
  • Monitor with db.currentOp({ op: "command", "command.createIndexes": { $exists: true } })

Performance Tips

  // Covered query — all fields in index, no FETCH needed
db.users.createIndex({ email: 1, name: 1, status: 1 })
db.users.find(
  { email: "[email protected]" },
  { name: 1, status: 1, _id: 0 }
)

// Hint to force specific index (debugging only)
db.orders.find({ userId: X }).hint({ userId: 1, createdAt: -1 })
  

Common Mistakes

  • Creating indexes on every field — slows writes, wastes disk
  • Wrong compound index field order — index exists but query still COLLSCAN
  • Indexing low-cardinality fields alone (e.g., boolean isActive)
  • Duplicate indexes: { a: 1, b: 1 } makes { a: 1 } redundant
  • Not monitoring $indexStats — unused indexes accumulate over years

Troubleshooting

Query uses wrong index

  db.orders.find({ status: "active", userId: X }).explain("executionStats")
// If wrong index chosen, use hint() temporarily, then fix index design
  

Index build fails — duplicate key

  // Unique index on field with duplicates
db.users.createIndex({ email: 1 }, { unique: true })
// Error: E11000 duplicate key — clean data first
db.users.aggregate([
  { $group: { _id: "$email", count: { $sum: 1 } } },
  { $match: { count: { $gt: 1 } } }
])
  

Index too large for RAM

  • Use partial indexes to reduce size
  • Archive old data to separate collections
  • Increase WiredTiger cache (cacheSizeGB)

Best Practices

  1. Design indexes from actual query patterns — not hypothetical ones
  2. Follow ESR rule for compound indexes
  3. Review $indexStats quarterly — drop unused indexes
  4. Keep total index size under ~50% of RAM for hot data
  5. Use partial indexes for sparse query patterns
  6. Test with explain("executionStats") before deploying new queries

Production Scenario: E-Commerce Orders

  // Access patterns:
// 1. User order history: find({ userId }).sort({ createdAt: -1 })
// 2. Admin by status: find({ status: "pending" }).sort({ createdAt: 1 })
// 3. Order lookup by ID: findOne({ orderNumber: "ORD-12345" })

db.orders.createIndex({ userId: 1, createdAt: -1 })
db.orders.createIndex({ status: 1, createdAt: 1 })
db.orders.createIndex({ orderNumber: 1 }, { unique: true })
  

Three indexes for three distinct patterns — resist merging into one mega-index.

What Comes Next

The aggregation framework uses indexes via $match and $sort stages — place them early in pipelines for maximum benefit.