Skip to content
Navigation

Type at least two characters. Search covers page titles, headings, tags and database names.

↑ ↓ to navigateEnter to openEsc to close0 pages
MongoDBintermediate

MongoDB Index Design

The ESR rule for compound indexes, covered queries, partial and TTL indexes, and how to find indexes that earn nothing.

3 min readIntermediateUpdated Edit this page

MongoDB indexes are B-trees over field paths. Without a matching index a query performs a collection scan, which on a large collection also evicts the working set from cache.

Compound Indexes

The order of fields follows the ESR rule: Equality, then Sort, then Range.

// Query: find a customer's recent paid orders above a value.
db.orders.find({ customerId: cid, status: "paid", totalCents: { $gt: 5000 } })
         .sort({ createdAt: -1 })
         .limit(20);
 
// Equality fields, then the sort field, then the range field.
db.orders.createIndex({ customerId: 1, status: 1, createdAt: -1, totalCents: 1 });

Putting the range field before the sort field forces an in-memory sort, which fails outright once it exceeds the sort memory limit unless the query can use an index or allow disk use.

Like other B-tree systems, a compound index serves any prefix of its fields — {a: 1, b: 1, c: 1} serves queries on a, on a, b, and on a, b, c, but not on b alone.

Sort direction matters for multi-field sorts: an index on {a: 1, b: -1} supports sorting by a ascending with b descending, and its exact inverse, but not a ascending with b ascending.

Covered Queries

If every field a query needs — filter, sort and projection — is in the index, MongoDB answers it from the index alone and never reads the documents.

db.orders.createIndex({ customerId: 1, createdAt: -1, status: 1, totalCents: 1 });
 
// Covered: _id must be excluded explicitly, since it is returned by default.
db.orders.find(
  { customerId: cid },
  { _id: 0, createdAt: 1, status: 1, totalCents: 1 }
).sort({ createdAt: -1 });

Confirm with explain(): the winning plan should show IXSCAN with no FETCH stage.

db.orders.find({ customerId: cid }, { _id: 0, status: 1 }).explain("executionStats");

Read totalKeysExamined, totalDocsExamined and nReturned. Docs examined far exceeding documents returned means the index is not selective enough; docs examined at zero means the query was covered.

Specialised indexes

// Partial: index only the subset that is queried.
db.orders.createIndex(
  { createdAt: -1 },
  { partialFilterExpression: { status: "pending" } }
);
 
// TTL: delete documents automatically after a period.
db.sessions.createIndex({ lastSeenAt: 1 }, { expireAfterSeconds: 3600 });
 
// Unique, with a partial filter so it applies only where the field exists.
db.users.createIndex(
  { email: 1 },
  { unique: true, partialFilterExpression: { email: { $exists: true } } }
);
 
// Text and wildcard.
db.articles.createIndex({ title: "text", body: "text" });
db.events.createIndex({ "attributes.$**": 1 });

Multikey indexes

An index on an array field indexes every element. This works well and has two consequences: an index entry per array element per document, and no compound index may contain more than one array field. Large arrays inflate index size quickly.

Finding index problems

// Usage since the last restart.
db.orders.aggregate([{ $indexStats: {} }]);
 
// All indexes and their sizes.
db.orders.stats().indexSizes;
 
// Queries that were slow, from the profiler.
db.setProfilingLevel(1, { slowms: 100 });
db.system.profile.find({ millis: { $gt: 100 } }).sort({ ts: -1 }).limit(10);

An index with accesses.ops of zero after a full business cycle is a candidate for removal — remembering that statistics reset on restart and that monthly jobs look unused for 29 days.