TT Lab
Blog Get started
Learn Learning paths Courses

MongoDB — The Judgement Behind Document Databases

Design Indexes by Order and Range

Continue in TT Lab

In one line

An index is not a question of "which field to put it on" but of "in what order, and holding what". Even with the same three fields, the keys scanned differ by thousands of times depending on the order, and the size and lifetime change depending on the range it holds (partial, TTL). You judge by the numbers in the execution plan.

Why it was needed — slow even with an index

Most reports of "I added an index, so why is it slow" are not cases where the index is missing but where it does not fit. Take a common query on a list screen.

db.orders.find({status: "shipped", amount: {$gte: 150000}}).sort({createdAt: -1}).limit(20)

If you build the index {status, amount, createdAt} in the order the fields appear in the query, it narrows by status and then scans the amount range. But the result of scanning a range is not in createdAt order. So a SORT stage appears in the plan, and it takes the thousands of matching documents, loads them all into memory, sorts them, and then cuts 20. limit 20 does not help at all.

How it works — ESR

A compound index is a single list sorted by value, starting from the first field. That is where the ordering rule comes from.

Order Field Reason
E Equality (status: "shipped") Narrows to one range. Inside that range, entries are sorted by the next field
S Sort (createdAt: -1) Walking the equality range in its already-sorted order makes the sort free
R Range (amount >= …) Filters straight from the keys while walking. Stops when 20 are filled

If you build it as {status, createdAt, amount}, the plan becomes LIMIT>FETCH>IXSCAN, and the keys scanned shrink to just enough to fill 20. A field placed after a range field is used only for filtering, not for seeking — that is the entire difference between ERS and ESR.

There are three more.

The order in which to read a plan

An execution plan is a tree. The outside (winningPlan) is what happens last, and following inputStage down brings you to what happens first. LIMIT>FETCH>IXSCAN is "scan the index → fetch the documents → cut at 20", and SORT>COLLSCAN is "scan everything → sort in memory". When reading, look at three numbers together.

Comparison Meaning
totalKeysExamined ≫ nReturned It used the index but the range is wide — suspect the order or a regex
totalDocsExamined ≫ nReturned It fetches documents and then filters — the filtering field is not in the index
totalDocsExamined = 0 It answered from the index alone (covered)

The planner briefly runs the candidate plans side by side, picks the one that produced more results first, and then caches that choice per query shape. Losing plans remain in rejectedPlans. So creating a new index clears the cache, and the plan for the same query may change — which is why you measure again after a fix. To measure one specific index on its own, force it with hint, and to measure without an index, give {$natural: 1}.

What it looks like in the field

What you do in the next lab

On 60,000 orders, you measure the same list query with no index, with ERS, and with ESR and compare the numbers, then measure covered queries, partial indexes, TTL, and regex prefixes in turn. At the end, you take an incident where three screens got slow and tell apart the different causes behind the same symptom.