MongoDB Indexing
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 index | With an index |
|---|---|
| COLLSCAN: reads every document | IXSCAN: reads only matching index entries |
| Gets slower as the collection grows | Stays fast as the collection grows |
| Sorting large results happens in memory | Results 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.
db.books.getIndexes()
// [ { v: 2, key: { _id: 1 }, name: '_id_' } ]
3. Creating Indexes
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.
4. Index Types
| Type | Example | Use 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 |
| Clustered | Set when creating the collection | Collections 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:
- Equality fields first: fields matched with an exact value.
- Sort fields next: fields used in
sort(). - Range fields last: fields with
$gt,$lt,$inover many values, and similar.
// 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
// 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
sparseoption.
7. Text, Geospatial and Wildcard Indexes
// 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
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
| Sign | Meaning | Fix |
|---|---|---|
COLLSCAN in the plan | No usable index | Create an index matching the filter |
totalDocsExamined much larger than nReturned | Index is not selective enough | Add or reorder fields (ESR) |
A SORT stage | Sorting in memory | Add the sort fields to the index in the right order |
totalDocsExamined: 0 with results | A covered query: answered from the index alone | Ideal |
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
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.
10. Creating Indexes from 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);
_idis 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(),$indexStatsand hidden indexes to tune indexes safely.