00:00

MongoDB Indexing

Last updated: 6 min read

Indexes are the single most important factor in MongoDB query performance. Without an index, MongoDB must read every document in a collection to answer a query. With the right index, it jumps straight to the matching documents, often turning seconds into milliseconds.

This tutorial explains how indexes work, every index type you are likely to need, how to design compound indexes with the ESR rule, how to read explain() output, and how to manage indexes safely in production.

1. How Indexes Work

An index is a separate, sorted data structure (a B-tree) that stores the values of one or more fields together with a pointer to each document. It works like the index at the back of a book: to find "replication", you look it up alphabetically and go straight to the right pages.

Without an indexWith an index
COLLSCAN: reads every documentIXSCAN: reads only matching index entries
Gets slower as the collection growsStays fast as the collection grows
Sorting large results happens in memoryResults can be returned already sorted

Indexes have a cost: they use disk and memory, and every insert, update and delete must update them too. The goal is the right indexes, not the most indexes.

2. The Default _id Index

Every collection automatically has a unique index on _id. You cannot drop it, and lookups by _id are always fast.

mongosh
db.books.getIndexes()
// [ { v: 2, key: { _id: 1 }, name: '_id_' } ]

3. Creating Indexes

Syntax
db.<collection>.createIndex(<keys>, <options>)

db.books.createIndex({ category: 1 })                 // ascending
db.books.createIndex({ price: -1 }, { name: "price_desc" })
db.books.createIndexes([ { title: 1 }, { "publisher.country": 1 } ])

1 means ascending and -1 descending. For a single-field index the direction does not matter, because MongoDB can read it both ways.

Index builds do not block reads and writes for most of their duration (since MongoDB 4.2). On a large collection they still use significant CPU and I/O, so build big indexes during quieter periods.

4. Index Types

TypeExampleUse for
Single field{ email: 1 }Queries on one field
Compound{ customerId: 1, createdAt: -1 }Queries that filter and sort on several fields
Multikey{ tags: 1 } (on an array field)Queries on array elements; created automatically for arrays
Text{ title: "text", summary: "text" }Simple keyword search
2dsphere{ location: "2dsphere" }Geospatial queries on GeoJSON
Hashed{ userId: "hashed" }Even data distribution for sharding
Wildcard{ "attributes.$**": 1 }Unpredictable field names, such as product attributes
ClusteredSet when creating the collectionCollections queried mainly by _id ranges

5. Compound Indexes and the ESR Rule

A compound index sorts by its first field, then by the second within each value of the first, and so on. Field order is therefore crucial. A reliable guideline is the ESR rule:

  1. Equality fields first: fields matched with an exact value.
  2. Sort fields next: fields used in sort().
  3. Range fields last: fields with $gt, $lt, $in over many values, and similar.
mongosh
// Query: one customer's orders above 500, newest first
db.orders.find({ customerId: "C-1", total: { $gt: 500 } }).sort({ createdAt: -1 })

// ESR: customerId (Equality), createdAt (Sort), total (Range)
db.orders.createIndex({ customerId: 1, createdAt: -1, total: 1 })

A compound index also serves queries on its prefixes. The index above supports queries on customerId alone, and on customerId plus createdAt, but not on createdAt alone.

6. Index Options

mongosh
// Unique: no two users with the same email
db.users.createIndex({ email: 1 }, { unique: true })

// Partial: index only documents that need it (smaller, faster)
db.orders.createIndex({ createdAt: -1 }, { partialFilterExpression: { status: "PENDING" } })

// TTL: delete sessions 30 minutes after lastSeen
db.sessions.createIndex({ lastSeen: 1 }, { expireAfterSeconds: 1800 })

// Case-insensitive lookups
db.users.createIndex({ username: 1 }, { collation: { locale: "en", strength: 2 } })
  • A unique index can be combined with a partial filter, for example "email must be unique among active users".
  • Queries use a partial index only if their filter includes the partial condition.
  • A query uses a collation index only if it specifies the same collation.
  • Partial indexes are the modern replacement for the older sparse option.

7. Text, Geospatial and Wildcard Indexes

mongosh
// Text search (one text index per collection)
db.books.createIndex({ title: "text", summary: "text" }, { weights: { title: 5 } })
db.books.find({ $text: { $search: "event streaming" } }, { score: { $meta: "textScore" } })
        .sort({ score: { $meta: "textScore" } })

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

// Wildcard: index every field under "attributes"
db.products.createIndex({ "attributes.$**": 1 })
db.products.find({ "attributes.screenSize": 15.6 })

For production search features, such as typo tolerance, autocomplete and relevance tuning, Atlas Search is far more capable than a text index.

8. Reading explain() Output

mongosh
const stats = db.orders.find({ customerId: "C-1" }).sort({ createdAt: -1 })
                       .explain("executionStats")
stats.queryPlanner.winningPlan       // look for IXSCAN vs COLLSCAN, and a SORT stage
stats.executionStats.nReturned        // documents returned
stats.executionStats.totalKeysExamined
stats.executionStats.totalDocsExamined
stats.executionStats.executionTimeMillis
SignMeaningFix
COLLSCAN in the planNo usable indexCreate an index matching the filter
totalDocsExamined much larger than nReturnedIndex is not selective enoughAdd or reorder fields (ESR)
A SORT stageSorting in memoryAdd the sort fields to the index in the right order
totalDocsExamined: 0 with resultsA covered query: answered from the index aloneIdeal
mongosh: a covered query
db.users.createIndex({ email: 1, name: 1 })
db.users.find({ email: "aditi@example.com" }, { _id: 0, email: 1, name: 1 })   // no documents read

9. Managing Indexes

mongosh
db.books.getIndexes()                      // list
db.books.aggregate([{ $indexStats: {} }])  // how often each index has been used
db.books.hideIndex("price_desc")           // planner ignores it; still maintained
db.books.unhideIndex("price_desc")
db.books.dropIndex("price_desc")           // remove
db.books.totalIndexSize()                  // bytes used by all indexes

A safe way to remove an index in production: check $indexStats for unused indexes, hide the candidate, watch performance for a while, and only then drop it.

On MongoDB Atlas, the Performance Advisor analyses slow queries and suggests indexes to create or remove.

10. Creating Indexes from Java

CreateIndexesExample.java
import com.mongodb.client.*;
import com.mongodb.client.model.*;
import org.bson.Document;
import java.util.concurrent.TimeUnit;

public class CreateIndexesExample {
    public static void main(String[] args) {
        try (MongoClient client = MongoClients.create("mongodb://localhost:27017")) {
            MongoDatabase db = client.getDatabase("bookstore");

            db.getCollection("orders").createIndex(
                    Indexes.compoundIndex(Indexes.ascending("customerId"),
                                          Indexes.descending("createdAt"),
                                          Indexes.ascending("total")));

            db.getCollection("users").createIndex(Indexes.ascending("email"),
                    new IndexOptions().unique(true));

            db.getCollection("sessions").createIndex(Indexes.ascending("lastSeen"),
                    new IndexOptions().expireAfter(30L, TimeUnit.MINUTES));

            db.getCollection("orders").listIndexes().forEach(ix -> System.out.println(ix.toJson()));
        }
    }
}

11. Best Practices

  • Design indexes from your real queries, including their sort order, not from the fields that "look important".
  • Apply the ESR rule to compound indexes, and reuse one compound index for several queries through its prefixes.
  • Keep the working set (frequently used data and indexes) in RAM.
  • Remove unused and redundant indexes: { a: 1 } is redundant when { a: 1, b: 1 } exists.
  • Avoid indexing low-selectivity fields on their own, such as a boolean flag.
  • Check new queries with explain("executionStats") before releasing them.

12. Summary

  • Indexes turn collection scans (COLLSCAN) into index scans (IXSCAN); _id is always indexed.
  • MongoDB supports single, compound, multikey, text, geospatial, hashed, wildcard and clustered indexes, with unique, partial, TTL and collation options.
  • Order compound index fields by Equality, Sort, Range.
  • Use explain(), $indexStats and hidden indexes to tune indexes safely.