MongoDB Cheatsheet
Indexes
Use this MongoDB reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Creating indexes
_id is indexed automatically and cannot be dropped. Everything else is on you.
A collection holds at most 64 indexes.
db.orders.createIndex({ placed_at: -1 }) db.orders.createIndex({ status: 1, placed_at: -1 }, { name: "status_recent" }) db.users.createIndex({ email: 1 }, { unique: true }) db.orders.createIndexes([ { key: { ref: 1 } }, { key: { "items.sku": 1 } } ]) db.orders.getIndexes() db.orders.dropIndex("status_recent") db.orders.dropIndex({ status: 1, placed_at: -1 }) // by key pattern db.orders.dropIndexes() // all but _id
| Option | Does |
|---|---|
name | Override the generated field_1_other_-1 name. Names cap at 127 bytes |
unique: true | Reject duplicate keys |
partialFilterExpression | Index only documents matching a filter |
sparse: true | Skip documents missing the field. Superseded by partial |
expireAfterSeconds | TTL index |
collation | Locale rules, which is how you get a case-insensitive index |
hidden: true | Maintained but invisible to the planner |
weights | Per-field weighting on a text index |
background: true | Obsolete since 4.2 and ignored. All builds are online now |
Index builds on a replica set take a brief exclusive lock at the start and end only, so
background no longer means anything. Passing it is harmless and marks your snippet as
pre-4.2.
Compound indexes and the ESR rule
Field order in a compound index decides which queries it serves. The rule is ESR: Equality, Sort, Range.
- Equality fields first, the ones tested with
$eqor a bare value. - Sort fields next, in sort order.
- Range fields last,
$gt$lt$in$neand friends.
// Query: equality on status, sort by placed_at desc, range on total db.orders.find({ status: "paid", total: { $gt: 100 } }) .sort({ placed_at: -1 }) // ESR index db.orders.createIndex({ status: 1, placed_at: -1, total: 1 })
Put the range before the sort and the index stops supplying order, so the server adds a
blocking SORT stage on top of the scan. That single reordering is the most common
cause of a "why is my sort slow" ticket.
| Index | Serves the query above |
|---|---|
{ status: 1, placed_at: -1, total: 1 } | Fully. Seek, ordered scan, filter in the index |
{ status: 1, total: 1, placed_at: -1 } | Partly. Range breaks the sort, blocking SORT appears |
{ placed_at: -1, status: 1 } | Order is free, but every date is scanned |
{ total: 1, status: 1 } | Worst of the three, no equality seek and no order |
The leftmost prefix rule
A compound index is usable for any leftmost prefix of its keys, never for a
non-prefix subset. An index on {a: 1, b: 1, c: 1} covers a, (a, b), and
(a, b, c).
| Query | Uses {a: 1, b: 1, c: 1} |
|---|---|
{ a: 1 } | Yes |
{ a: 1, b: 2 } | Yes |
{ a: 1, c: 3 } | Partly, seeks on a then filters c in the index |
{ b: 2 } | No seek. At best a full index scan |
{ b: 2, c: 3 } | No |
sort({ a: 1, b: 1 }) | Yes |
sort({ a: 1, b: -1 }) | No. Mixed directions need a matching or fully inverted index |
Direction only matters for sorting, and only relatively. A B-tree scans backwards, so
{a: 1, b: -1} also serves sort({a: -1, b: 1}) but not sort({a: 1, b: 1}).
Because of the prefix rule, {a: 1, b: 1} makes a separate {a: 1} index redundant, so
drop it.
Index types
| Type | Create with | For |
|---|---|---|
| Single field | { f: 1 } | Equality, ranges, sorts |
| Compound | { a: 1, b: -1 } | Up to 32 fields, ESR-ordered |
| Multikey | Automatic on any array field | Array membership |
| Text | { title: "text", body: "text" } | $text search. One text index per collection |
| 2dsphere | { loc: "2dsphere" } | GeoJSON, $near $geoWithin $geoIntersects |
| 2d | { loc: "2d" } | Legacy coordinate pairs, flat plane |
| Hashed | { _id: "hashed" } | Even shard distribution. No range queries |
| Wildcard | { "$**": 1 } or { "attrs.$**": 1 } | Unknown or user-defined field names. Compound wildcard is 7.0+ |
| TTL | { at: 1 }, { expireAfterSeconds: 3600 } | Automatic expiry |
| Clustered | clusteredIndex on createCollection (5.3+) | Documents stored in _id order |
db.posts.createIndex({ title: "text", body: "text" }, { weights: { title: 10 } }) db.posts.find({ $text: { $search: "\"index scan\" -legacy" } }, { score: { $meta: "textScore" } }) .sort({ score: { $meta: "textScore" } }) db.places.createIndex({ loc: "2dsphere" }) db.places.find({ loc: { $near: { $geometry: { type: "Point", coordinates: [-9.14, 38.72] }, $maxDistance: 2000 } } }) db.places.find({ loc: { $geoWithin: { $centerSphere: [[-9.14, 38.72], 0.01] } } }) db.products.createIndex({ "attrs.$**": 1 }) // any attrs.* field db.sessions.createIndex({ last_seen: 1 }, { expireAfterSeconds: 1800 })
Text search supports quoted phrases and -term negation but has no relevance tuning
beyond weights, no stemming choices past default_language, and one index per
collection. On Atlas, $search is the real answer. TTL deletion is done by a
single-threaded background task that wakes roughly every 60 seconds, so documents
outlive their expiry by up to a minute, longer under load. expireAfterSeconds: 0
means "expire at the value of the indexed date", which is how you schedule per-document
deletion.
Multikey indexes
Indexing an array field produces a multikey index automatically, with one index entry per element. You do not ask for it and you cannot turn it off.
| Restriction | Detail |
|---|---|
| One array per compound index | { "a.x": 1, "b.y": 1 } fails at insert time if a and b are both arrays |
| No covered queries | The server must fetch the document to reconstruct the array |
| Cannot be a shard key | Multikey fields are ineligible |
| Hashed indexes | Cannot be multikey |
| Bounds are per element | { scores: { $gt: 90, $lt: 10 } } still matches across two elements |
A big array means a big index. A document with a 1,000-element array contributes 1,000 keys, so array size drives index size directly.
Unique, partial, and sparse
unique alone treats a missing field as null, so a second document without the field
collides. The classic fix was sparse, but partialFilterExpression is strictly better.
// Many documents may omit email, at most one may hold a given email db.users.createIndex({ email: 1 }, { unique: true, partialFilterExpression: { email: { $type: "string" } } }) // Only index the rows you query db.orders.createIndex({ placed_at: -1 }, { partialFilterExpression: { status: "pending" } }) // One active subscription per user db.subs.createIndex({ user_id: 1 }, { unique: true, partialFilterExpression: { active: true } }) // Case-insensitive unique email db.users.createIndex({ email: 1 }, { unique: true, collation: { locale: "en", strength: 2 } })
| Variant | Indexes | Uniqueness applies to |
|---|---|---|
unique | Every document | Every document, missing counts as one null |
unique + sparse | Documents where the field exists | Only those |
unique + partialFilterExpression | Documents matching the filter | Only those, and the filter can be any condition |
The planner uses a partial index only when it can prove the query implies the index
filter, so query with the literal { status: "pending" }, not a variable that is
sometimes something else. A collation index is only used by a query carrying the same
collation.
Reading explain()
Three verbosity levels. queryPlanner (the default) shows the chosen plan,
executionStats actually runs it and reports counters, allPlansExecution adds the
rejected candidates.
db.orders.find({ status: "paid" }).sort({ placed_at: -1 }) .explain("executionStats") db.orders.explain("executionStats") .aggregate([{ $match: { status: "paid" } }, { $group: { _id: "$region" } }])
| Plan stage | Means |
|---|---|
COLLSCAN | Full collection scan. No usable index |
IXSCAN | Index seek or scan. What you want, check indexName |
FETCH | Loaded the document after the index. Normal unless you wanted covered |
PROJECTION_COVERED | Answered from the index alone, zero documents read |
SORT | Blocking in-memory sort. The index did not supply the order |
SORT_MERGE | Merged several ordered scans, usually from an $or |
LIMIT · SKIP | Folded into the plan |
OR | One IXSCAN per $or branch |
EXPRESS_IXSCAN | 8.0 fast path for _id and simple unique-index point queries, replacing the old IDHACK |
The four numbers that matter, all under executionStats:
| Field | Healthy | Trouble |
|---|---|---|
nReturned | The documents you asked for | — |
totalKeysExamined | Close to nReturned | Far above it means the index is too loose |
totalDocsExamined | 0 if covered, else ≈ nReturned | Equal to the collection size means COLLSCAN |
executionTimeMillis | — | Compare across plans, not in isolation |
// A healthy indexed query // { nReturned: 20, totalKeysExamined: 20, totalDocsExamined: 20, // executionStages: { stage: 'FETCH', inputStage: { stage: 'IXSCAN', ... } } } // The shape you are hunting for // { nReturned: 20, totalKeysExamined: 0, totalDocsExamined: 2400000, // executionStages: { stage: 'COLLSCAN' } }
totalKeysExamined divided by nReturned is the selectivity ratio. At 1 the index is
doing exactly the right work. At 1,000 you are scanning an index to throw almost all of
it away, which usually means a missing leading equality field.
Maintenance
Hide an index before you drop it. A hidden index is still maintained, so unhiding is instant, while dropping and rebuilding a large index is not.
db.orders.hideIndex("status_recent") // 4.4+, planner ignores it // run your workload, watch for regressions db.orders.unhideIndex("status_recent") // instant rollback db.orders.dropIndex("status_recent") // only once you are sure // Which indexes has nobody used since the last restart? db.orders.aggregate([{ $indexStats: {} }]) .forEach(i => print(i.name, i.accesses.ops))
| Command | Use |
|---|---|
db.orders.getIndexes() | Definitions, including options |
db.orders.aggregate([{ $indexStats: {} }]) | accesses.ops per index, since the last mongod restart |
db.orders.totalIndexSize() | Bytes across all indexes |
db.orders.hideIndex(name) / unhideIndex(name) | Safe drop rehearsal |
db.orders.reIndex() | Rebuild. Rarely needed, blocks writes, avoid on a replica set |
$indexStats counters reset when mongod restarts, so judge an index on a full
business cycle rather than an hour. Unused indexes are not free: every one of them is
extra work on every insert, update, and delete.