MongoDBDatabases

MongoDB Indexes: Types, Design, and Performance Tuning

Learn MongoDB index types: single-field, compound, multikey, text, and geospatial. Use explain() to verify index usage and design indexes for your access patterns. Lesson 7 of MongoDB & NoSQL Mastery.

TT
Sarah Mitchell
6 min read
MongoDB Indexes: Types, Design, and Performance Tuning

A collection scan (COLLSCAN) reads every document to find matches — fine for a hundred documents, catastrophic for a million. Indexes let MongoDB jump directly to matching documents. Designing the right indexes for your access patterns is the single most impactful performance intervention available in MongoDB.

Previous: Lesson 6 — The Aggregation Pipeline


How MongoDB Indexes Work

MongoDB indexes are B-tree data structures stored separately from the collection data. When a query includes a filter or sort that matches an index, MongoDB uses an IXSCAN (index scan) instead of a COLLSCAN (collection scan).

Every collection has a default index on _id. Additional indexes must be created explicitly.


Index Types

Single-Field Index

javascript
// Ascending index on email
db.users.createIndex({ email: 1 })

// Descending index (identical performance for single-field indexes)
db.users.createIndex({ createdAt: -1 })

// Index with options
db.users.createIndex(
  { email: 1 },
  { unique: true, name: "unique_email" }
)

Compound Index

A compound index covers multiple fields. Field order matters: MongoDB can use the index for queries on the first field, the first two fields, the first three, and so on — but not for queries that skip the first field (left-prefix rule).

javascript
// Index on status, then createdAt
db.orders.createIndex({ status: 1, createdAt: -1 })

// This query uses the index ✓
db.orders.find({ status: "shipped" }).sort({ createdAt: -1 })

// This also uses the index (leading field matches) ✓
db.orders.find({ status: "shipped" })

// This does NOT use the index (skips status) ✗
db.orders.find({}).sort({ createdAt: -1 })

ESR Rule for compound indexes: Equality fields first, Sort fields second, Range fields last.

javascript
// Query: { status: "shipped", amount: { $gte: 100 } } sorted by createdAt
// Correct index order: equality → sort → range
db.orders.createIndex({ status: 1, createdAt: -1, amount: 1 })

Multikey Index

When you index an array field, MongoDB creates a multikey index — one index entry per array element:

javascript
db.products.createIndex({ tags: 1 })

// Now this query uses the index
db.products.find({ tags: "wireless" })

Note: A compound index cannot have more than one multikey field.

Text Index

Supports full-text search on string fields:

javascript
// Create a text index on one or more fields
db.articles.createIndex({ title: "text", body: "text" })

// Weighted text index (title matches count more)
db.articles.createIndex(
  { title: "text", body: "text" },
  { weights: { title: 10, body: 1 }, name: "article_text" }
)

// Query using $text
db.articles.find({ $text: { $search: "mongodb indexing performance" } })

// Sort by relevance score
db.articles.find(
  { $text: { $search: "mongodb" } },
  { score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } })

Only one text index per collection is allowed. For production full-text search, consider MongoDB Atlas Search (built on Lucene).

Geospatial Index

javascript
// 2dsphere index for GeoJSON data
db.locations.createIndex({ coordinates: "2dsphere" })

// Find locations within 5km of a point
db.locations.find({
  coordinates: {
    $near: {
      $geometry: { type: "Point", coordinates: [-73.935242, 40.730610] },
      $maxDistance: 5000
    }
  }
})

Sparse and Partial Indexes

javascript
// Sparse: only index documents where the field exists
db.users.createIndex({ phone: 1 }, { sparse: true })

// Partial: index only documents matching a filter expression
db.orders.createIndex(
  { customerId: 1, createdAt: -1 },
  { partialFilterExpression: { status: { $in: ["shipped", "processing"] } } }
)
// Smaller, faster index — only active orders are indexed

TTL Index — Automatic Document Expiry

javascript
// Delete session documents 1 hour after createdAt
db.sessions.createIndex(
  { createdAt: 1 },
  { expireAfterSeconds: 3600 }
)

Analysing Queries with explain()

explain() reveals how MongoDB executes a query. Always use it when investigating slow queries.

javascript
// Verbosity levels: "queryPlanner", "executionStats", "allPlansExecution"
db.orders.find({ status: "shipped" }).explain("executionStats")

Key Fields to Check

text
executionStats.executionStages.stage
  COLLSCAN  → full collection scan — needs an index
  IXSCAN    → index scan — good
  FETCH     → retrieve full documents after index scan — expected
  SORT      → in-memory sort — add the sort field to your index

executionStats.totalDocsExamined   → documents read
executionStats.totalKeysExamined   → index entries scanned
executionStats.nReturned           → documents returned

Healthy ratio: nReturned ≈ totalDocsExamined
Unhealthy:     nReturned << totalDocsExamined (scanning too many docs)

Before and After Example

javascript
// Without index: 50,000 docs examined for 12 results
db.orders.find({ status: "shipped", customerId: ObjectId("user_123") })
  .explain("executionStats")
// totalDocsExamined: 50000, nReturned: 12 → COLLSCAN

// Create index
db.orders.createIndex({ customerId: 1, status: 1 })

// With index: 12 docs examined for 12 results
db.orders.find({ status: "shipped", customerId: ObjectId("user_123") })
  .explain("executionStats")
// totalDocsExamined: 12, nReturned: 12 → IXSCAN

Covered Queries

A covered query is satisfied entirely from the index — MongoDB never reads the collection data:

javascript
// Index covers name and price
db.products.createIndex({ brand: 1, name: 1, price: 1 })

// Covered query — only indexed fields in filter and projection, _id excluded
db.products.find(
  { brand: "Logitech" },
  { name: 1, price: 1, _id: 0 }
).explain("executionStats")
// totalDocsExamined: 0  (reads from index only)

Managing Indexes

javascript
// List all indexes on a collection
db.orders.getIndexes()

// Index sizes
db.orders.stats().indexSizes

// Drop a specific index by name
db.orders.dropIndex("unique_email")

// Drop all non-_id indexes
db.orders.dropIndexes()

// Hide an index (test removing it without actually dropping it — MongoDB 4.4+)
db.orders.hideIndex("status_1_createdAt_-1")
// Run your workload, check explain() output, then drop or unhide
db.orders.unhideIndex("status_1_createdAt_-1")

Index Design Guidelines

GuidelineRationale
Build indexes for your actual queries, not hypotheticallyEvery index costs write performance and memory
Use explain("executionStats") to verify index usageA query plan that looks correct may still COLLSCAN
Follow the ESR rule for compound indexesEquality → Sort → Range field ordering
Avoid indexing high-cardinality fields with low selectivityAn index on a boolean field with 50/50 distribution is often useless
Use partial indexes for filtered workloadsSmaller index, faster writes for unindexed documents
Monitor with MongoDB Atlas Performance Advisor or currentOp()Catches slow queries automatically in production