MongoDB Performance Best Practices

A MongoDB query that feels instant on a 500-document test collection can grind to a halt once that collection holds 50 million documents in production. Performance in MongoDB is almost always about one thing: whether the server can find the documents it needs without looking at every document in the collection. This lesson teaches you how to read the query planner’s decisions, design indexes that actually get used, and spot the pipeline and schema patterns that quietly destroy performance as data grows.

Overview: How the Query Planner and Indexes Work

Every query you run is handed to MongoDB’s query planner before any documents are touched. The planner looks at the query shape (which fields you’re filtering, sorting, or projecting) and considers every index on the collection as a candidate access path. If a usable index exists, the planner can perform an index scan (IXSCAN): it walks a B-tree structure keyed on the indexed field(s) and jumps directly to matching entries, each of which points at the exact location of a document. If no usable index exists, MongoDB falls back to a collection scan (COLLSCAN): it reads every single document in the collection, in storage order, and tests each one against your filter. On a small collection the difference is invisible. On a large one, COLLSCAN turns a query that should take milliseconds into one that takes seconds or minutes, and it does this on every single execution, not just the first.

An index in MongoDB is a separate, sorted data structure (by default a B-tree) that stores the indexed field’s value alongside a pointer to the full document. Indexes make reads faster but are not free: every insert, update, or delete that touches an indexed field has to update every index that covers that field, and each index consumes RAM and disk space. This is why “just index everything” is not the answer — the goal is to index exactly the fields your real query patterns actually filter, sort, or join on.

When you have more than one field to filter on, a compound index (an index on multiple fields together) is usually far more effective than several single-field indexes, but only if you order the fields correctly. MongoDB’s own documentation calls this the ESR rule: put Equality fields first, then Sort fields, then Range fields. A compound index built in this order lets the planner narrow to an exact equality match, walk the index in the already-sorted order your query needs (avoiding an expensive in-memory sort), and then filter the remaining range condition — all within the same index traversal.

Syntax

The tools you’ll use constantly for performance work are createIndex() and explain().

db.<collection>.createIndex( <keyPattern>, <options> )
db.<collection>.find( <query> ).explain( <verbosity> )
db.<collection>.explain( <verbosity> ).aggregate( <pipeline> )
  • keyPattern — an object mapping field names to 1 (ascending) or -1 (descending), e.g. { status: 1, createdAt: -1 }. Field order defines the ESR structure of the index.
  • options — an object such as { unique: true } or { name: "custom_name" } to control index behavior and naming.
  • verbosity — one of "queryPlanner" (plan only, default), "executionStats" (plan plus actual document counts and timing — the one you’ll use most), or "allPlansExecution" (stats for every plan the optimizer considered).

Examples

Example 1: Spotting a collection scan

// orders has 500,000 documents, no index on status
db.orders.find({ status: "shipped" }).explain("executionStats")
{
  queryPlanner: {
    winningPlan: { stage: "COLLSCAN", filter: { status: { '$eq': 'shipped' } } }
  },
  executionStats: {
    nReturned: 1200,
    totalDocsExamined: 500000,
    totalKeysExamined: 0,
    executionTimeMillis: 341
  }
}

The COLLSCAN stage and totalDocsExamined: 500000 tell the real story: to return 1,200 matching orders, MongoDB read all 500,000 documents and discarded 498,800 of them. Adding an index fixes this directly:

db.orders.createIndex({ status: 1 })
{ status_1: 'status_1' }
db.orders.find({ status: "shipped" }).explain("executionStats")
{
  queryPlanner: {
    winningPlan: { stage: "FETCH", inputStage: { stage: "IXSCAN", indexName: "status_1" } }
  },
  executionStats: {
    nReturned: 1200,
    totalDocsExamined: 1200,
    totalKeysExamined: 1200,
    executionTimeMillis: 4
  }
}

Now totalDocsExamined equals nReturned — MongoDB examined only the documents it actually returned, via an IXSCAN followed by a FETCH to pull the full document for each matching index entry. Execution time dropped from 341ms to 4ms.

Example 2: Applying the ESR rule with a compound index

// Query: equality on status, then sort by createdAt
db.orders.createIndex({ status: 1, createdAt: -1 })

db.orders.find({ status: "shipped" })
  .sort({ createdAt: -1 })
  .explain("executionStats")
{
  queryPlanner: {
    winningPlan: {
      stage: "FETCH",
      inputStage: { stage: "IXSCAN", indexName: "status_1_createdAt_-1" }
    }
  },
  executionStats: { nReturned: 1200, totalDocsExamined: 1200, executionTimeMillis: 5 }
}

Because createdAt is stored in descending order inside the index right after the status equality match, MongoDB reads matching entries in the exact order the sort requires. Note the absence of a SORT stage in the plan — if you had built the index as { createdAt: -1, status: 1 } instead (range/sort field before equality field, violating ESR), the planner could not use the index as cleanly for this query shape and would likely add a separate, memory-hungry SORT stage.

Example 3: Filtering before joining in aggregation

db.orders.explain("executionStats").aggregate([
  { $match: { status: "shipped", createdAt: { $gte: new Date("2026-01-01") } } },
  { $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
  { $group: { _id: "$customer.region", totalRevenue: { $sum: "$total" } } }
])
{
  stages: [
    { '$cursor': { queryPlanner: { winningPlan: { inputStage: { stage: 'IXSCAN', indexName: 'status_1_createdAt_-1' } } } } },
    { '$lookup': {} },
    { '$group': {} }
  ]
}

Placing $match first lets the aggregation’s initial $cursor stage use the status_1_createdAt_-1 index, shrinking the document stream before the expensive $lookup join and $group accumulation ever run. Every document that $match filters out here is one fewer document the pipeline has to join and group later.

How It Works Step by Step

  1. MongoDB parses the query shape and checks the query plan cache for a previously chosen winning plan matching that shape.
  2. If no cached plan exists, the planner generates candidate plans — one per usable index, plus a collection-scan fallback — and runs a brief trial (multi-plan competition) of each.
  3. The plan that returns the required results while examining the fewest documents/index keys within a short time budget is chosen as the winning plan and cached for future queries with the same shape.
  4. During aggregation, stages execute in the pipeline order you wrote them; MongoDB’s own aggregation optimizer will sometimes reorder or merge stages internally (for example moving a later $match earlier when it’s safe to do so), but you should never rely on that — write pipelines with cheap, index-backed filtering first.
  5. On a write, every index covering a changed field is updated synchronously as part of the same operation, which is why excessive indexing slows down writes even though it speeds up reads.

Common Mistakes

Mistake 1: Querying a large collection with no supporting index

Wrong:

db.orders.find({ status: "shipped", region: "APAC" })
// No index on status or region — full COLLSCAN on every call

Corrected:

db.orders.createIndex({ status: 1, region: 1 })
db.orders.find({ status: "shipped", region: "APAC" })

Mistake 2: Running $lookup before filtering

Wrong (joins the full, unfiltered collection before narrowing it):

db.orders.aggregate([
  { $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
  { $match: { status: "shipped" } }
])

Corrected (filter first, and project only the fields the join actually needs):

db.orders.aggregate([
  { $match: { status: "shipped" } },
  { $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
  { $project: { "customer.name": 1, total: 1, status: 1 } }
])

Mistake 3: Building a compound index in the wrong field order

Wrong (range field before equality field breaks the ESR rule):

db.orders.createIndex({ createdAt: -1, status: 1 })
db.orders.find({ status: "shipped" }).sort({ createdAt: -1 })

Corrected (equality field first, matching how the query actually filters):

db.orders.createIndex({ status: 1, createdAt: -1 })

Best Practices

  • Run explain("executionStats") on any query touching more than a few thousand documents before it ships — check that totalDocsExamined is close to nReturned.
  • Follow the ESR rule (Equality, Sort, Range) when ordering fields in a compound index.
  • Put $match (and $limit, when correctness allows) as early as possible in an aggregation pipeline so later stages process fewer documents.
  • Use $project to drop unneeded fields before a $lookup or $group, reducing the data each stage has to move around.
  • Avoid unbounded arrays growing inside a single document (a running log of every event, for example) — the whole array is read and rewritten on every update, and documents have a 16MB cap.
  • Don’t create an index “just in case” — every unused index still costs write latency and RAM; periodically review db.collection.aggregate([{ $indexStats: {} }]) and drop indexes with zero recent usage.
  • Ensure your working set (frequently accessed indexes and documents) fits in available RAM; heavy disk I/O for routine queries is a strong signal you need more memory, better indexes, or smaller documents.
  • Use db.setProfilingLevel(1, { slowms: 100 }) in development or staging to log slow operations and find real-world hot spots instead of guessing.

Practice Exercises

  • You have a db.products collection with 2 million documents. Users filter by category (equality) and price (range), and results are sorted by rating descending. Design the compound index using the ESR rule, and write the find() call that would use it.
  • Take the aggregation from Example 3 and rewrite it to also cap results to orders from the last 30 days and project away every customer field except region before the $group. Explain in one sentence why each change reduces work for the pipeline.
  • Run explain("executionStats") on a query in your own database (or a sandbox one). If you see COLLSCAN and a large gap between totalDocsExamined and nReturned, create an appropriate index and re-run explain() to confirm the stage changes to IXSCAN.

Summary

  • Without a usable index, MongoDB performs a COLLSCAN, examining every document in the collection for every query — this scales badly as collections grow.
  • explain("executionStats") is the single most important diagnostic tool: compare totalDocsExamined to nReturned and check whether the winning plan is IXSCAN or COLLSCAN.
  • Compound indexes should follow the ESR rule — Equality fields, then Sort fields, then Range fields — to let MongoDB avoid both extra scanning and in-memory sorts.
  • In aggregation pipelines, push $match (and projections) as early as possible, especially before $lookup and $group, to shrink the document stream before expensive stages run.
  • Indexes speed up reads but slow down writes and consume RAM — index deliberately based on real query patterns, not preemptively.