Skip to main content
MongoDB · lesson 3 of 10
MongoDB intermediate Lesson 3 of 10

explain() Proves It: 200,000 Docs Scanned vs. a Covered Query That Touches Zero

A query against 200,000 documents takes 65ms and scans every single one without an index. One index later it's 2ms. A covered query on top of that touches zero documents at all — explain() proves each claim.

By SkillByExample · Published

mongodb indexes explain performance query-optimization

The same 200,000-document collection, queried three different ways — a full scan, an indexed lookup, and a query that never touches a document at all.

Seeding a realistically sized collection

docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const genres = ["scifi", "fantasy", "mystery", "romance", "history", "biography"];
const bulk = [];
for (let i = 0; i < 200000; i++) {
  bulk.push({
    title: "Book " + i,
    author: "Author " + (i % 5000),
    genre: genres[i % genres.length],
    price: 5 + (i % 50),
    published_year: 1950 + (i % 75)
  });
  if (bulk.length === 20000) { db.books.insertMany(bulk, { ordered: false }); bulk.length = 0; }
}
if (bulk.length) db.books.insertMany(bulk, { ordered: false });
print("total:", db.books.countDocuments());
'
total: 200000

200,000 books, 5,000 distinct authors — enough that a full scan has real, measurable cost, not a rounding error.

The bug: a query with no index to use

docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const result = db.books.find({ author: "Author 4999" }).explain("executionStats");
print("stage:", result.executionStats.executionStages.stage);
print("docsExamined:", result.executionStats.totalDocsExamined);
print("nReturned:", result.executionStats.nReturned);
print("executionTimeMillis:", result.executionStats.executionTimeMillis);
'
stage: COLLSCAN
docsExamined: 200000
nReturned: 40
executionTimeMillis: 65

COLLSCAN means exactly what it sounds like: every one of the 200,000 documents was examined to find the 40 that actually matched. docsExamined: 200000 next to nReturned: 40 is the smoking gun — a query this selective (0.02% of the collection) had no business reading the whole thing.

The fix: an index on the filtered field

docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
db.books.createIndex({ author: 1 });
const result = db.books.find({ author: "Author 4999" }).explain("executionStats");
print("stage:", result.executionStats.executionStages.stage);
print("docsExamined:", result.executionStats.totalDocsExamined);
print("executionTimeMillis:", result.executionStats.executionTimeMillis);
'
stage: FETCH
docsExamined: 40
executionTimeMillis: 2

docsExamined dropped from 200,000 to exactly 40 — the number of documents that actually matched, nothing more. Execution time went from 65ms to 2ms, over 30x faster. The top-level stage is FETCH, not IXSCAN directly — the index (IXSCAN, visible as FETCH’s child stage) narrows down which documents to read, and FETCH is the step that actually reads them.

Compound indexes: order matters

docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
db.books.createIndex({ genre: 1, price: 1 });
const result = db.books.find({ genre: "scifi", price: { $gte: 40 } }).explain("executionStats");
print("stage:", result.executionStats.executionStages.stage);
print("docsExamined:", result.executionStats.totalDocsExamined);
print("executionTimeMillis:", result.executionStats.executionTimeMillis);
'
stage: FETCH
docsExamined: 9334
executionTimeMillis: 10

{ genre: 1, price: 1 } supports filtering on genre alone, or genre and price together, efficiently — index entries sort by genre first, price second within each genre, the same left-to-right rule SQL composite indexes follow. A query filtering on price alone wouldn’t use this index efficiently at all.

The covered query: zero documents touched

docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const result = db.books.find({ genre: "scifi", price: { $gte: 40 } }, { genre: 1, price: 1, _id: 0 }).explain("executionStats");
print("stage:", result.executionStats.executionStages.stage);
print("docsExamined:", result.executionStats.totalDocsExamined);
print("executionTimeMillis:", result.executionStats.executionTimeMillis);
'
stage: PROJECTION_COVERED
docsExamined: 0
executionTimeMillis: 4

docsExamined: 0. Both the filter fields (genre, price) and the projected fields (genre, price, with _id explicitly excluded) are entirely satisfied by the genre_1_price_1 index — no document ever needs to be read from the collection at all. This is the fastest a query can possibly be: the answer lives entirely in the index.

Practice

1. Query a field with no index and confirm it's still a full scan.
docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const result = db.books.find({ published_year: 2000 }).explain("executionStats");
print("stage:", result.executionStats.executionStages.stage);
print("docsExamined:", result.executionStats.totalDocsExamined);
'
stage: COLLSCAN
docsExamined: 200000

published_year has no index of its own — genre_1_price_1 and author_1 don’t help here at all, since neither includes published_year. Same full-collection cost as the very first example.

2. List every index currently on the collection.
docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
printjson(db.books.getIndexes().map(idx => idx.name));
'
[ '_id_', 'author_1', 'genre_1_price_1' ]

Every collection gets the _id_ index automatically and for free — author_1 and genre_1_price_1 are the two created earlier in this lesson. getIndexes() is the direct way to audit what actually exists, rather than trying to remember what was created across a long session.

3. Predict, then confirm, what happens querying by price alone against the genre-price compound index.
docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const result = db.books.find({ price: { $gte: 40 } }).explain("executionStats");
print("stage:", result.executionStats.executionStages.stage);
'
stage: COLLSCAN

Confirms the left-to-right rule from earlier: { genre: 1, price: 1 } can’t efficiently serve a query that filters on price without also filtering on genre — MongoDB falls back to a full scan rather than using an index it can’t apply the way the query needs.

Next: aggregation pipelines — and the stage-order bug that returns wrong results with no error.

Frequently Asked Questions

How do I know if a query is actually using an index, rather than guessing from how fast it feels?
explain("executionStats") is the real answer — it reports the actual stage (COLLSCAN means no index was used, IXSCAN/FETCH means one was, PROJECTION_COVERED means the index alone satisfied the query) along with totalDocsExamined and totalKeysExamined. A query that feels fast on a small test collection can still be doing a full COLLSCAN that will not scale; explain() is what catches that before production traffic does.
Why did the indexed query still show a FETCH stage instead of just IXSCAN?
IXSCAN only narrows down which documents match by walking the index — it doesn't have the fields the query actually asked for beyond what's in the index. FETCH is the step that goes and reads those full documents from the collection. A plain find() on a compound-indexed field still needs FETCH unless the projection asks only for fields already in the index, which is what the covered-query example does.
What makes a query 'covered' specifically?
A covered query is answered entirely from an index, with zero documents read from the collection — both every field the query filters on and every field the projection returns have to be present in one index for that to happen. That's why the covered example only asked for genre and price (both in the genre_1_price_1 compound index) with _id explicitly excluded; asking for any other field would force a FETCH.
Does the order of fields in a compound index matter?
Yes — a compound index on { genre: 1, price: 1 } efficiently supports queries filtering on genre alone or on genre and price together, but not price alone, because index entries are sorted by genre first. This is the same left-to-right prefix rule SQL composite indexes follow, and getting the field order wrong is one of the most common reasons a 'the index exists but isn't being used' bug report turns out to be user error, not a MongoDB bug.
Should I just add an index on every field I ever query?
No — every index speeds up reads that use it but slows down every write to the collection (each insert/update has to update every index too) and costs memory to keep in RAM for good performance. Index the fields real queries actually filter or sort on, verified with explain(), not every field that theoretically could be queried.