Module 2 · Lecture 14

Indexing & the Aggregation Framework

Making queries fast, and turning 15,000 raw bookings into the handful of numbers a management meeting actually needs

⏱ 90 minutes 🎯 CLO3 📍 Prerequisites: Lecture 13 (CRUD and query operators) 🔧 Lab: explain() on bdma.rides + seven pipelines

Contents

  1. Learning Objectives
  2. Session Timeline
  3. The Cost of a Collection Scan
  4. Creating Indexes — Syntax and Types
  5. Compound Indexes and the ESR Rule
  6. Reading explain("executionStats")
  7. The Price of an Index
  8. The Aggregation Pipeline Mental Model
  9. Seven Business Questions, Seven Pipelines
  10. Optimising and Materialising Pipelines
  11. Key Takeaways

Learning Objectives

Session Timeline

TimeSegmentActivity
0–5 minRecapLecture 13's query operators — now make them fast, then make them analytical
5–15 minThe Scan ProblemCOLLSCAN demo on rides; the arithmetic at 15,000 vs 15,000,000 documents
15–28 minIndex TypescreateIndex(), direction, single-field, compound, multikey, text, TTL
28–40 minThe ESR RuleCompound field ordering worked through on a rides query
40–56 minexplain() LabRead executionStats; build a covered query; discuss index cost and Performance Advisor
56–70 minPipeline ModelStages as a conveyor belt; the SQL mapping; accumulators
70–84 minPipeline LabSeven business questions answered against bdma.rides
84–90 minWrap-Up$out/$merge, and how Lecture 15 consumes these results

Section 1: The Cost of a Collection Scan

Every query you wrote in Lecture 13 worked. None of them were fast — they simply ran against a collection small enough that slowness was invisible. That is the most dangerous state a system can be in, because the problem is not absent, it is merely waiting for growth.

When MongoDB has no index that can help, it performs a collection scan, reported in query plans as COLLSCAN. It reads every document in the collection, one by one, and tests each against your filter. There is no shortcut, no early exit — even if the single matching document happens to be the first one read, the engine cannot know that until it has checked the last one.

// This reads all 15,000 documents to return roughly 1,139 of them
db.rides.find({ "route.pickup": "Connaught Place" })

Why This Is O(n), and Why That Matters Commercially

The work done by a collection scan is directly proportional to the number of documents. Double the collection, double the work. There is no efficiency of scale; there is only linear cost. Put numbers against it:

Collection sizeDocuments read per queryRough response timeBusiness reality
15,000 (our dataset)15,000~20–40 msFeels instant. The problem is completely hidden.
1,500,000 (100×)1,500,000~2–4 sA dashboard tile that visibly stalls on load
15,000,000 (1,000×)15,000,000~20–40 sRequest timeouts; the report is abandoned, not slow

The management translation: a 400 ms dashboard query becomes roughly 12 seconds at 30× the data. That is not a degradation, it is a category change — the difference between a report people check every morning and a report people stop opening. Indexing decisions made when a collection is small determine whether the product survives its own success.

And the cost is not only time. A collection scan pulls every document off disk and through RAM, evicting genuinely useful data from the cache. One badly planned analytics query can therefore slow down the unrelated transactional queries running beside it. A single scan is rarely the whole problem; a scan running fifty times a minute on a live cluster is.

The Index Alternative

An index is a separate, ordered data structure — MongoDB uses a B-tree — that stores the values of one or more fields together with pointers to the documents holding them. Because it is sorted, the engine can navigate it the way you use a book index: jump to the right region, read only the matching entries, and follow the pointers to exactly those documents.

The B-tree turns the search from O(n) into roughly O(log n). The practical consequence of a logarithm is worth stating plainly: finding a document among 15 million costs only about twice as much work as finding one among 15 thousand. The query plan changes from COLLSCAN to IXSCAN, and the cost of growth all but disappears.

The one-line version for a non-technical audience: without an index, the database reads the entire book to find one sentence. With an index, it reads the index page, turns to page 412, and reads one sentence. The book getting longer barely changes the second approach.

Section 2: Creating Indexes — Syntax and Types

Indexes are created on a collection with createIndex(). The argument is a document of field-to-direction pairs, where 1 means ascending and -1 descending.

// Single-field, ascending
db.rides.createIndex({ status: 1 })

// Descending — useful when the dominant sort is "largest first"
db.rides.createIndex({ booking_value: -1 })

// Named explicitly, and built without blocking other operations
db.rides.createIndex(
  { "route.pickup": 1 },
  { name: "pickup_idx" }
)

// Inspect and remove
db.rides.getIndexes()
db.rides.dropIndex("pickup_idx")

Note the dotted path: "route.pickup" indexes a field inside an embedded document exactly as if it were top level. Quotation marks are required because of the dot.

When Does Direction Actually Matter?

For a single-field index, almost never. A B-tree can be walked in either direction, so an ascending index on booking_value serves sort({ booking_value: -1 }) perfectly well.

Direction becomes decisive the moment a compound index must support a multi-field sort. The index can only serve a sort if the requested sort matches the index order either exactly, or exactly inverted.

db.rides.createIndex({ vehicle_type: 1, booking_value: -1 })

// SERVED — matches the index exactly
db.rides.find().sort({ vehicle_type: 1, booking_value: -1 })

// SERVED — the exact mirror image of the index
db.rides.find().sort({ vehicle_type: -1, booking_value: 1 })

// NOT SERVED — neither the same nor the mirror; MongoDB must sort in memory
db.rides.find().sort({ vehicle_type: 1, booking_value: 1 })

Why the failed case hurts: an in-memory sort has a hard 100 MB limit in MongoDB. Exceed it and the query does not run slowly — it fails outright with a Sort exceeded memory limit error. A report that worked in testing can break in production purely because the dataset grew past that line.

The Five Index Types You Will Use

TypeCreated withWhat it is forExample on rides
Single-field{ status: 1 }Filtering or sorting on one fieldEvery "completed rides only" query
Compound{ status: 1, date: 1 }Filtering on several fields at once; supports sorts on a prefix"Completed rides in March, newest first"
Multikey{ tags: 1 }Automatic when the field is an array — one index entry per elementA surge_flags: ["rain","peak"] array
Text{ "cancellation.reason": "text" }Word-level search inside free text, with stemmingSearching cancellation reasons by keyword
TTL{ createdAt: 1 }, { expireAfterSeconds: … }Automatic deletion of documents after an intervalExpiring driver-location pings after 24 hours

Multikey indexes

You do not ask for a multikey index; MongoDB creates one automatically when the indexed field contains an array. It stores a separate index entry for every element, which is what makes find({ tags: "peak" }) fast. The cost follows directly: a document with twenty array elements writes twenty index entries. Indexing a large array is an expensive decision, and only one array field may appear in a single compound index.

Text indexes

db.rides.createIndex({ "cancellation.reason": "text" })

db.rides.find({ $text: { $search: "breakdown vehicle" } })

A text index tokenises, lowercases and stems the content, so "cancelling" matches "cancel". A collection may have only one text index, though it can cover several fields. For anything resembling real search — typo tolerance, relevance tuning, faceting — Atlas Search (built on Lucene) is the production answer; the basic text index is best thought of as keyword matching.

TTL indexes

// Delete documents 24 hours after their createdAt timestamp
db.driver_pings.createIndex(
  { createdAt: 1 },
  { expireAfterSeconds: 86400 }
)

A background process sweeps roughly every 60 seconds and removes expired documents. Two constraints matter: the indexed field must hold a real BSON Date (a string will be silently ignored), and deletion is approximate, not instantaneous. TTL is the cleanest way to meet a data-retention policy — "we do not keep raw location data beyond 30 days" becomes one line of configuration rather than a cron job nobody maintains.

Also worth knowing by name: unique indexes ({ unique: true }) enforce no-duplicates — the natural choice for booking_id; partial indexes index only documents matching a filter, which is ideal here because booking_value exists only on completed and incomplete bookings; and 2dsphere indexes power geospatial queries such as "drivers within 3 km", the obvious next step for a ride-hailing dataset.

// A partial index: skip the ~5,000 documents with no booking_value at all
db.rides.createIndex(
  { booking_value: -1 },
  { partialFilterExpression: { booking_value: { $exists: true } } }
)

Section 3: Compound Indexes and the ESR Rule

A compound index covers up to 32 fields, and the order of those fields is the single most consequential decision in MongoDB performance tuning. The same three fields in a different order can be the difference between examining 400 documents and examining 15,000.

The Prefix Rule

A compound index can serve any query that uses a left-prefix of its fields. Given { status: 1, vehicle_type: 1, booking_value: -1 }:

Query filters onIndex usable?
statusYes — left prefix
status + vehicle_typeYes — left prefix
status + vehicle_type + booking_valueYes — the whole index
vehicle_type aloneNo — skips the leading field
booking_value aloneNo — skips two leading fields

This is why one well-ordered compound index often replaces three single-field indexes: it serves the full query and every prefix of it. Fewer indexes, less RAM, cheaper writes.

The ESR Rule

Order compound index fields as Equality, then Sort, then Range.

E — fields tested for exact equality (status: "Completed") come first.
S — fields the query sorts on come next.
R — fields tested with a range operator ($gt, $lt, $gte, $in over a span) come last.

The reasoning is geometric. An equality match on the leading field narrows the B-tree to one contiguous block of entries. Inside that block the entries are still ordered by the next field, so a sort on it is free — the engine simply walks the block. A range, by contrast, selects a span of entries rather than a point, so anything after a range field is no longer in a single sorted run and must be re-sorted in memory. Put the range last and nothing is left behind it to spoil.

Worked Example on rides

The business question: "Show me completed Go Sedan rides worth more than ₹500, most recent first."

db.rides.find({
  status: "Completed",          // E — equality
  vehicle_type: "Go Sedan",     // E — equality
  booking_value: { $gt: 500 }   // R — range
}).sort({ date: -1 })           // S — sort

Apply ESR mechanically: two equality fields, then the sort field, then the range field.

db.rides.createIndex({
  status: 1,          // E
  vehicle_type: 1,    // E
  date: -1,           // S
  booking_value: 1    // R
})
Field orderWhat happensVerdict
{status, vehicle_type, date, booking_value}Equality narrows to Completed + Go Sedan; the block is already ordered by date; the range is applied lastESR — correct
{status, vehicle_type, booking_value, date}The range fans the scan across many values, so date is no longer in sorted order — an in-memory SORT stage appearsRange before sort — slow
{booking_value, status, vehicle_type, date}Leads with a range: the scan starts broad and stays broadWorst of the three

What to watch for in class: all three indexes will be used. All three produce IXSCAN. Only the ESR ordering produces a totalDocsExamined close to nReturned and no separate SORT stage. "The index is being used" is not evidence that the index is good — which is exactly why the next section exists.

Section 4: Reading explain("executionStats")

Appending .explain("executionStats") to a query makes MongoDB run it and report exactly what it did. This is the single most valuable diagnostic skill in the module, and it is entirely learnable in ten minutes.

db.rides.find({ "route.pickup": "Connaught Place" })
        .explain("executionStats")

The Four Numbers That Matter

FieldMeaningWhat you want to see
stageThe access method: COLLSCAN, IXSCAN, FETCH, SORT, PROJECTION_COVEREDIXSCAN. Any bare COLLSCAN on a large collection is a finding. A SORT stage means the sort was done in memory.
nReturnedDocuments the query actually returnedYour denominator — this is the useful work
totalDocsExaminedDocuments the engine had to readAs close to nReturned as possible
totalKeysExaminedIndex entries readAlso close to nReturned; much higher means a poorly ordered index
executionTimeMillisWall-clock time for the queryThe headline, but the least stable number — it varies with cache state

The headline metric is a ratio, not a time: totalDocsExamined ÷ nReturned. A ratio of 1:1 is perfect — every document read was a document returned. A ratio of 10:1 is tolerable. A ratio of 1,000:1 means the database did a thousand times more work than the answer required, and it will scale exactly that badly.

Prefer the ratio over executionTimeMillis because the ratio is hardware-independent and cache-independent. A query showing 12 ms on a warm laptop cache and a 1,000:1 ratio is a production incident waiting for a cold start.

Before and After on Our Collection

// BEFORE — no index on route.pickup
{
  stage: "COLLSCAN",
  nReturned: 1139,
  totalDocsExamined: 15000,   // ratio ≈ 13 : 1
  executionTimeMillis: 28
}

db.rides.createIndex({ "route.pickup": 1 })

// AFTER
{
  stage: "IXSCAN",
  nReturned: 1139,
  totalKeysExamined: 1139,
  totalDocsExamined: 1139,    // ratio = 1 : 1
  executionTimeMillis: 3
}

Note that even the "bad" version returns in 28 ms on 15,000 documents. Scale the collection a thousandfold and the indexed query stays in single-digit milliseconds while the scan moves into tens of seconds. You are not tuning today's performance; you are buying immunity to growth.

Covered Queries

There is a tier above a 1:1 ratio. If every field the query needs — both the filtered fields and the returned fields — lives inside the index, MongoDB never touches the documents at all. It answers entirely from the index. This is a covered query, and totalDocsExamined is 0.

db.rides.createIndex({ status: 1, vehicle_type: 1, booking_value: 1 })

// Covered: filter and projection both live entirely in the index.
// _id must be explicitly excluded, because _id is not in this index.
db.rides.find(
  { status: "Completed", vehicle_type: "Go Sedan" },
  { _id: 0, vehicle_type: 1, booking_value: 1 }
).explain("executionStats")

// stage: "PROJECTION_COVERED"
// totalDocsExamined: 0        ← the documents were never read

Why this is worth engineering for: indexes are far smaller than the documents they point at and are therefore much more likely to be resident in RAM. A covered query can be an order of magnitude faster than a 1:1 indexed query simply because it avoids disk entirely. The pattern is ideal for exactly the high-frequency, few-fields reads that dashboards make — which makes it the most relevant optimisation for the Lecture 15 build.

Section 5: The Price of an Index

If indexes only made things faster, the correct strategy would be to index everything. Three costs make that strategy wrong, and a manager who can name them will not approve a bad one.

Cost 1 — Every Write Pays for Every Index

An insert writes one document plus one entry in every index on the collection. An update that changes an indexed field must remove and reinsert the corresponding entry, in sorted position. A collection with twelve indexes does roughly thirteen units of write work per insert instead of one.

For a ride-hailing platform ingesting bookings continuously, this is not theoretical. Indexes added for the analytics team are paid for, permanently, by the operational write path.

Cost 2 — Indexes Consume RAM, and RAM Is the Whole Game

Your working set is the data and indexes your queries touch regularly. MongoDB's performance profile is essentially binary: if the working set fits in RAM, queries are fast; if it does not, the engine starts reading from disk and performance falls off a cliff — frequently by a factor of 50 or more.

The failure mode nobody expects: indexes compete with documents for the same cache. Adding a twelfth index to speed up one quarterly report can push the working set past available RAM and slow down every other query on the cluster. The report gets faster; the application gets slower. Check index size with db.rides.totalIndexSize() before you add another.

Cost 3 — Unused Indexes Are Pure Overhead

An index that no query uses still costs write time and still occupies RAM. It contributes nothing. MongoDB tracks usage, so this is measurable rather than a matter of opinion:

db.rides.aggregate([{ $indexStats: {} }])
// Each entry reports accesses.ops — the number of operations
// that have used that index since the server last started.
// A long-lived server showing ops: 0 is a drop candidate.

A Workable Indexing Policy

1

Index the queries you actually run, not the fields you happen to have. Start from the three to five highest-frequency queries and work backwards.

2

Prefer one well-ordered compound index to several single-field ones. The prefix rule means one index often serves a family of queries.

3

Measure before and after with explain(). If the examined-to-returned ratio did not improve, the index was the wrong one — drop it.

4

Review $indexStats quarterly and drop what nothing uses. Index sprawl accumulates silently.

5

Build on a live system with care. Index builds consume CPU and I/O; on a replica set, build during a low-traffic window.

Atlas Performance Advisor — the Practical Tool

You do not have to find slow queries by intuition. Atlas continuously profiles real traffic and, under Clusters → Performance Advisor, recommends specific indexes ranked by expected impact, showing the queries that triggered each recommendation. It also flags indexes that appear unused or redundant.

Use it, but do not outsource judgement to it. Performance Advisor optimises each slow query in isolation; it does not know your write volume or your RAM budget. Accept a recommendation when you can explain in ESR terms why it is right — which, after this lecture, you can. The companion tools are the Real-Time Performance Panel (live operations) and the Profiler (historical slow-query log).

Section 6: The Aggregation Pipeline Mental Model

Indexing makes retrieval fast. It does not answer a management question. "Which pickup point loses us the most revenue to cancellations?" is not a find() — it requires grouping, computing and ranking. That is the aggregation framework.

The Conveyor Belt

A pipeline is an ordered array of stages. Documents enter at the top, each stage transforms the stream, and the output of one stage is the input to the next. A stage may filter documents out, reshape them, combine many into one, or split one into many.

db.rides.aggregate([
  { $match:  { status: "Completed" } },              // 15,000 → 9,343
  { $group:  { _id: "$vehicle_type",
               revenue: { $sum: "$booking_value" } } }, // 9,343 → 7
  { $sort:   { revenue: -1 } },                      // 7 → 7, ordered
  { $limit:  3 }                                     // 7 → 3
])

Read it top to bottom as a sentence: keep only completed rides, group them by vehicle type summing booking value, order by revenue descending, keep the top three. That returns Premier Sedan ₹5,36,855, Go Sedan ₹4,91,473 and Uber XL ₹4,31,927.

The SQL Mapping

You already know this material in another notation. The translation is close to exact.

SQLAggregation stageNote
WHERE$matchSame operators as find(). Can use an index if it runs first.
GROUP BY$groupThe grouping key is always _id
HAVING$match after $groupSame stage, different position — position is the meaning
SELECT col, expr$project / $addFields$project replaces the shape; $addFields adds to it
ORDER BY$sort1 ascending, -1 descending
LIMIT / OFFSET$limit / $skipOrder matters: $skip then $limit
LEFT OUTER JOIN$lookupAlways a left outer join; results arrive as an array
COUNT(*)$count or { $sum: 1 }$sum: 1 inside $group counts per group
no clean equivalent$unwindExplodes an array into one document per element
several queries$facetRuns multiple sub-pipelines over one input in a single pass

The Stage Vocabulary

StageDoesOn rides
$matchFilters documents{ status: "Completed" } — drops 5,657 documents
$groupCollapses documents into one per key, applying accumulatorsRevenue per vehicle_type
$projectChooses and computes the output fieldsKeep booking_id and a computed fare_per_km
$addFields / $setAdds computed fields, keeping everything else (identical stages)Add a cancellation_rate_pct after $group
$sortOrders the stream{ revenue: -1 }
$limit / $skipTruncates / offsets the streamTop 10 pickups; pagination
$unwindOne input document with an n-element array becomes n documentsSplitting an array of surge flags or stops
$lookupLeft outer join to another collectionEnrich rides with a customers collection
$countReplaces the stream with a single count documentHow many rides exceeded ₹1,000
$facetRuns several sub-pipelines on the same input simultaneouslyA whole KPI header row in one query
$bucketGroups into explicit numeric boundaries — a histogramFare bands: 0–100, 100–250, 250–500, 500+

Accumulators

Accumulators are the expressions used inside $group to reduce many documents to one value.

AccumulatorReturnsTypical use here
$sumTotal; { $sum: 1 } countsTotal revenue, booking counts
$avgMean, ignoring missing valuesAverage fare ₹235.73, average distance 10.87 km
$min / $maxSmallest / largestCheapest and dearest ride in a group
$pushArray of all values, duplicates keptEvery reason recorded for a pickup point
$addToSetArray of distinct valuesWhich vehicle types serve a given pickup
$first / $lastFirst / last value in the groupEarliest booking per customer — sort first, or the result is arbitrary

A trap worth naming: $avg ignores documents where the field is missing — it does not treat them as zero. On rides this is exactly right for booking_value, since the field is absent on cancelled bookings. But it means $avg: "$booking_value" over the whole collection silently gives you the average completed fare, not the average across all bookings. Always make the intended population explicit with a $match, so a reader of your pipeline can see which question you answered.

Working With Our String Dates

In rides, booked_at ("2024-03-14T18:22:07Z") and date ("2024-03-14") are strings, not BSON Date values. This mirrors a great deal of real imported data, and gives us three options:

// 1. Substring extraction — fast, no conversion, works because ISO strings
//    sort lexicographically in the same order as the dates they represent
{ $substr: ["$date", 0, 7] }                       // "2024-03"
{ $toInt: { $substr: ["$booked_at", 11, 2] } }     // 18

// 2. Convert, then use the real date operators
{ $dateFromString: { dateString: "$booked_at" } }  // or the shorthand $toDate
{ $hour:  { $dateFromString: { dateString: "$booked_at" } } }
{ $month: { $dateFromString: { dateString: "$booked_at" } } }
{ $year:  { $dateFromString: { dateString: "$booked_at" } } }

// 3. Convert and re-format for a human-readable label
{ $dateToString: {
    format: "%Y-%m",
    date: { $dateFromString: { dateString: "$booked_at" } }
} }                                                // "2024-03"

The lesson underneath the workaround: storing dates as strings is a modelling mistake we are living with deliberately. Real Date values support range queries, arithmetic, time-zone handling and date-aware indexes; strings support none of these without conversion on every single read. Converting 15,000 documents per query is cheap; converting 15 million is a daily tax. Fix data types at ingestion, not in every downstream pipeline.

Section 7: Seven Business Questions, Seven Pipelines

Every pipeline below runs against bdma.rides and produces the stated answer. Type them; do not paste them.

1. What is the completion rate by vehicle type?

The pattern to learn here is $cond inside $sum — a conditional count that lets you compute a numerator and a denominator in a single pass over the data.

db.rides.aggregate([
  { $group: {
      _id: "$vehicle_type",
      total: { $sum: 1 },
      completed: {
        $sum: { $cond: [{ $eq: ["$status", "Completed"] }, 1, 0] }
      },
      revenue: { $sum: "$booking_value" }
  }},
  { $addFields: {
      completion_rate_pct: {
        $round: [{ $multiply: [{ $divide: ["$completed", "$total"] }, 100] }, 2]
      }
  }},
  { $sort: { revenue: -1 } }
])
Vehicle typeBookingsCompletion rateRevenueAvg fare
Premier Sedan1,95462.13%₹5,36,855₹442.22
Go Sedan2,85461.77%₹4,91,473₹278.77
Uber XL1,23162.79%₹4,31,927₹558.77

Read the result, not just the output. Every vehicle type sits within a point of 62%. Completion is therefore not a vehicle problem — the failure cause is common to the platform, so segmenting by vehicle is a dead end. Revenue, by contrast, varies more than tenfold across the fleet. A good analyst reports the dimension that discriminates and explicitly rules out the one that does not.

2. What is revenue by month?

db.rides.aggregate([
  { $match: { status: "Completed" } },
  { $group: {
      _id: { $substr: ["$date", 0, 7] },     // "2024-03"
      revenue: { $sum: "$booking_value" },
      rides:   { $sum: 1 },
      avg_fare: { $avg: "$booking_value" }
  }},
  { $project: {
      _id: 0,
      month: "$_id",
      rides: 1,
      revenue: { $round: ["$revenue", 0] },
      avg_fare: { $round: ["$avg_fare", 2] }
  }},
  { $sort: { month: 1 } }
])

March is the strongest month at ₹1,99,972 across 859 completed rides; June is the weakest at ₹1,66,260. The twelve months sum to the ₹22,02,449 of completed booking value.

The $dateFromString version produces the same grouping and is what you would write if the field could hold mixed formats:

{ $group: {
    _id: { $dateToString: {
      format: "%Y-%m",
      date: { $dateFromString: { dateString: "$booked_at" } }
    }},
    revenue: { $sum: "$booking_value" }
}}

3. Which hour of the day is busiest?

db.rides.aggregate([
  { $group: {
      _id: { $toInt: { $substr: ["$booked_at", 11, 2] } },
      bookings:  { $sum: 1 },
      completed: {
        $sum: { $cond: [{ $eq: ["$status", "Completed"] }, 1, 0] }
      }
  }},
  { $addFields: {
      failure_rate_pct: {
        $round: [{ $multiply: [
          { $divide: [{ $subtract: ["$bookings", "$completed"] }, "$bookings"] }, 100
        ]}, 2]
      }
  }},
  { $sort: { bookings: -1 } },
  { $limit: 5 }
])
HourBookingsFailure rate
18:001,37337.14%
19:001,24635.96%
09:001,17236.18%
08:001,07636.71%
17:001,06239.92%

This is the finding of the session. Demand is bimodal — a morning commute peak at 08:00–09:00 and a larger evening peak at 17:00–19:00. And the worst failure rate among the hours that actually carry volume is 17:00 at 39.92% — higher than the 18:00 peak's 37.14%, even though 18:00 takes 1,373 bookings to 17:00's 1,062.

Supply is arriving late to a demand spike that occurs at the same time every single day. That is an operational problem with an operational fix: driver incentives timed to 16:30, not 18:00.

Now Re-Rank by Rate — and Watch the Answer Fall Apart

The pipeline above ranks by volume. Change one line to rank by rate instead, and the result stops being useful:

  { $sort: { failure_rate_pct: -1 } },
  { $limit: 5 }
HourBookingsFailure rateIs this a finding?
03:004643.48%No — 46 bookings. Twenty failures instead of nineteen moves this figure by more than two points.
07:0063741.60%Worth a look, but a third of 17:00's volume
16:0076440.45%Worth a look — and it sits directly before 17:00
17:001,06239.92%Yes — a high rate on real volume

A rate without a denominator is not a finding. Ranked by rate alone, 03:00 tops the table at 43.48% — and it is noise. Forty-six bookings in a year is roughly one every eight days; the rate swings by whole percentage points on a single changed outcome. Acting on it means staffing drivers at 3 a.m. to fix a problem that affects twenty rides, while 17:00 quietly fails four hundred.

The fix is one stage. Add a minimum-volume guard after the $group — SQL's HAVING — so the ranking only ever considers hours carrying real traffic:

  { $match: { bookings: { $gte: 1000 } } },   // ← guard before ranking
  { $sort:  { failure_rate_pct: -1 } }

Five hours clear 1,000 bookings — 08:00, 09:00, 17:00, 18:00 and 19:00 — and 17:00 leads them at 39.92%. That is the number to take to an operations meeting, and the guard is what makes it defensible.

Where to set the threshold is a judgement, not a formula. State it out loud and put it in the pipeline where a reviewer can see it. "Worst failure rate, among hours with at least 1,000 bookings" is an honest, reproducible claim. "Worst failure rate" — unqualified — is the same query with the inconvenient half deleted, and you will be asked about 03:00 by the first person who checks.

4. Which pickup points have the worst cancellation rates?

The same trap as 03:00, in a different dimension — so apply the same guard. Rank only pickup points carrying real volume, using a $match after $group, which is SQL's HAVING.

db.rides.aggregate([
  { $group: {
      _id: "$route.pickup",
      bookings: { $sum: 1 },
      cancelled: {
        $sum: { $cond: [
          { $in: ["$status",
                  ["Cancelled by Driver", "Cancelled by Customer", "No Driver Found"]] },
          1, 0
        ]}
      },
      revenue: { $sum: "$booking_value" }
  }},
  { $match: { bookings: { $gte: 1000 } } },        // HAVING — ignore thin samples
  { $addFields: {
      cancellation_rate_pct: {
        $round: [{ $multiply: [{ $divide: ["$cancelled", "$bookings"] }, 100] }, 2]
      }
  }},
  { $sort: { cancellation_rate_pct: -1 } },
  { $limit: 10 }
])
Pickup pointBookingsCancellation rate
Noida Sector 621,20933.50%
New Delhi Railway Station1,17933.16%
Connaught Place1,13932.84%
Indira Gandhi International Airport1,18332.80%
Gurgaon Cyber City1,17831.24%

Only five pickup points clear the 1,000-booking threshold, so $limit: 10 returns five rows. The spread is narrow — 31.24% to 33.50% — which again says the cause is systemic rather than local. Note the definition baked into the $in array: "cancelled" here means driver cancellation, customer cancellation, or no driver found. Write the definition into the pipeline and state it beside the number, because a rate is meaningless until its numerator is defined.

5. Why are rides cancelled, and by whom?

A two-level grouping key. Set _id to an object and every distinct combination of its fields becomes a group.

db.rides.aggregate([
  { $match: { "cancellation.by": { $exists: true } } },
  { $group: {
      _id: { by: "$cancellation.by", reason: "$cancellation.reason" },
      count: { $sum: 1 }
  }},
  { $group: {
      _id: "$_id.by",
      total: { $sum: "$count" },
      reasons: { $push: { reason: "$_id.reason", count: "$count" } }
  }},
  { $unwind: "$reasons" },
  { $project: {
      _id: 0,
      cancelled_by: "$_id",
      reason: "$reasons.reason",
      count: "$reasons.count",
      share_pct: {
        $round: [{ $multiply: [{ $divide: ["$reasons.count", "$total"] }, 100] }, 2]
      }
  }},
  { $sort: { cancelled_by: 1, share_pct: -1 } }
])

This pipeline teaches three techniques at once: $group on a composite _id, a second $group that rolls the first one up to get a per-side total, and $push plus $unwind to carry detail rows through a grouping and then flatten them again. That group-then-regroup-then-unwind idiom is how you express "percentage of its own subtotal".

Cancelled byReasonShare of that side
Driver (2,698)Customer related issue27.02%
More than permitted people24.68%
Personal & Car related issues24.17%
Customer coughing/sick24.13%
Customer (1,022)Driver not moving toward pickup20.65%
AC not working20.55%
Driver asked to cancel20.35%
Change of plans19.86%
Wrong address18.59%

The asymmetry is the story. Drivers cancel 2,698 rides against customers' 1,022 — a 2.6:1 ratio. Yet "Driver asked to cancel" appears as a customer reason in 20.35% of customer cancellations, which means a further slice of apparent customer cancellations are really driver-initiated. The reported split understates the driver side. Supply-side behaviour, not demand-side behaviour, is where the intervention belongs.

6. Does driver rating vary by vehicle type?

Ratings exist only on completed rides, so the $match is mandatory rather than cosmetic. The $exists guard makes the intent explicit and survives any dirty record.

db.rides.aggregate([
  { $match: {
      status: "Completed",
      "ratings.driver": { $exists: true }
  }},
  { $group: {
      _id: "$vehicle_type",
      rides: { $sum: 1 },
      avg_driver_rating:   { $avg: "$ratings.driver" },
      avg_customer_rating: { $avg: "$ratings.customer" },
      worst: { $min: "$ratings.driver" },
      best:  { $max: "$ratings.driver" }
  }},
  { $project: {
      _id: 0,
      vehicle_type: "$_id",
      rides: 1,
      avg_driver_rating:   { $round: ["$avg_driver_rating", 2] },
      avg_customer_rating: { $round: ["$avg_customer_rating", 2] },
      worst: 1, best: 1
  }},
  { $sort: { avg_driver_rating: -1 } }
])

Every vehicle type lands between 4.39 and 4.42, against a fleet average driver rating of 4.41 and a customer rating of 4.38. The honest analytical conclusion is a negative one: rating does not discriminate between vehicle types, so it cannot explain the 37.71% failure rate. Reporting that clearly is more valuable than manufacturing a difference out of the second decimal place.

Run the pipeline without the $match and compare. The averages are identical, because $avg ignores the 5,657 documents with no ratings field. The numbers agree — but only one of the two pipelines says what population it measured. Explicit beats accidental, because the next person to read your code cannot see the accident.

7. Can we compute the whole KPI header in one query?

$facet runs several independent sub-pipelines against the same input stream and returns all their results in a single document. One pass over the collection, one network round trip, one object for the dashboard to consume.

db.rides.aggregate([
  { $facet: {

    headline: [
      { $group: {
          _id: null,                                  // null = one grand total
          total_bookings: { $sum: 1 },
          completed: {
            $sum: { $cond: [{ $eq: ["$status", "Completed"] }, 1, 0] }
          },
          completed_value: { $sum: "$booking_value" },
          avg_distance_km: { $avg: "$ride_distance" }
      }},
      { $project: {
          _id: 0, total_bookings: 1, completed: 1,
          completion_rate_pct: {
            $round: [{ $multiply: [
              { $divide: ["$completed", "$total_bookings"] }, 100] }, 2]
          },
          completed_value: { $round: ["$completed_value", 0] },
          avg_distance_km: { $round: ["$avg_distance_km", 2] }
      }}
    ],

    by_status: [
      { $group: { _id: "$status", bookings: { $sum: 1 } } },
      { $sort: { bookings: -1 } }
    ],

    payment_mix: [
      { $match: { payment_method: { $exists: true } } },
      { $group: { _id: "$payment_method", bookings: { $sum: 1 } } },
      { $sort: { bookings: -1 } }
    ],

    fare_bands: [
      { $match: { booking_value: { $exists: true } } },
      { $bucket: {
          groupBy: "$booking_value",
          boundaries: [0, 100, 250, 500, 1000],
          default: "1000+",
          output: {
            rides: { $sum: 1 },
            revenue: { $sum: "$booking_value" }
          }
      }}
    ],

    top_routes: [
      { $match: { status: "Completed" } },
      { $group: {
          _id: { pickup: "$route.pickup", drop: "$route.drop" },
          rides: { $sum: 1 },
          revenue: { $sum: "$booking_value" }
      }},
      { $sort: { rides: -1 } },
      { $limit: 10 }
    ]

  }}
])

The headline facet returns 15,000 bookings, 9,343 completed, a 62.29% completion rate, ₹22,02,449 of completed value and 10.87 km average distance. by_status returns the five statuses — Completed 9,343, Cancelled by Driver 2,698, No Driver Found 1,036, Cancelled by Customer 1,022, Incomplete 901. payment_mix returns UPI 45.03%, Cash 25.31%, Uber Wallet 11.79%, Credit Card 10.79%, Debit Card 7.08%.

Two things to take from this pipeline. First, _id: null is how you ask for a grand total — it puts every document into one group. Second, $facet is the natural shape for a dashboard header: five tiles that would otherwise be five separate queries become one. The trade-off is real and worth stating — a $facet sub-pipeline cannot use an index, because the input stream has already been read. Put any index-eligible $match before the $facet, never only inside it.

Bonus: Enriching With $lookup

Our rides documents hold a customer_id but no customer detail. If a customers collection existed, $lookup would join it — always as a left outer join, always returning an array.

db.rides.aggregate([
  { $match: { status: "Completed", booking_value: { $gt: 1000 } } },
  { $lookup: {
      from: "customers",
      localField: "customer_id",
      foreignField: "customer_id",
      as: "customer"
  }},
  { $unwind: { path: "$customer", preserveNullAndEmptyArrays: true } },
  { $project: {
      _id: 0, booking_id: 1, booking_value: 1,
      customer_name: "$customer.name",
      city: "$customer.city"
  }}
])

$unwind converts the one-element array into a plain sub-document. preserveNullAndEmptyArrays: true keeps rides whose customer record is missing — without it, $unwind silently drops them, which is the single most common way an aggregation quietly loses rows.

$lookup is the stage to use sparingly. It executes a lookup per input document, so always $match down to the smallest possible set first, and ensure the foreignField is indexed. An unindexed $lookup against a large collection is a collection scan repeated once per input document — precisely the cost structure that document modelling (embed what you read together) exists to avoid.

Section 8: Optimising and Materialising Pipelines

The Golden Rule: $match and $limit as Early as Possible

Every stage pays for the documents it receives. Filtering at the top of the pipeline means every later stage processes a smaller stream, and the saving compounds down the chain.

// SLOW — groups all 15,000 documents, then throws most of the result away
db.rides.aggregate([
  { $group: { _id: "$vehicle_type", revenue: { $sum: "$booking_value" } } },
  { $match: { _id: "Go Sedan" } }
])

// FAST — filters to 2,854 documents first; the $group does a fifth of the work
db.rides.aggregate([
  { $match: { vehicle_type: "Go Sedan" } },
  { $group: { _id: "$vehicle_type", revenue: { $sum: "$booking_value" } } }
])

The second reason is the more important one. Fewer documents is merely arithmetic. The decisive point is that only a $match in the first position can use an index. Once any stage has transformed the stream, the documents flowing through the pipeline are no longer the indexed documents on disk, and every later $match becomes an in-memory filter over everything produced so far. An early $match is an IXSCAN; a late $match is a scan of intermediate results.

The same logic applies to $sort: a $sort at the head of a pipeline can be served by an index, while a $sort after a $group must sort in memory — acceptable on seven vehicle types, dangerous on a million customer IDs. And $sort immediately followed by $limit is specially optimised: the engine keeps only the top n in memory rather than sorting the whole stream.

MongoDB's query optimiser will move some $match stages earlier on its own, but it can only do so when it can prove the move is safe. Write the pipeline in the right order yourself and the question never arises. Verify with:

db.rides.aggregate([ … ]).explain("executionStats")
// Check the first stage: IXSCAN is the goal, COLLSCAN is the warning.

A Checklist for Any Pipeline You Write

1

$match first, on indexed fields, as narrowly as the question allows.

2

$project away fields you will not use, before any $group or $lookup. Less data moving through each stage means less memory per stage.

3

$sort + $limit together when you need a top-N, so the engine can apply its top-N optimisation.

4

Place $lookup late and on an indexed foreignField, after the stream has been narrowed.

5

Mind the 100 MB per-stage memory limit. If a stage legitimately needs more, pass { allowDiskUse: true } — but treat needing it as a signal that the pipeline should be filtering earlier.

Materialising Results: $out and $merge

Both stages must be last in the pipeline, and both write the output to a collection instead of returning it to the client.

$out$merge
EffectReplaces the target collection entirelyInserts, updates or merges document by document
Use whenA full nightly rebuildIncremental refresh of a rolling summary
Indexes on targetDropped and lostPreserved
RiskDestructive — it will overwrite a populated collection without warningSafe, but you must choose the matching key with care
// Nightly rebuild of a small summary collection
db.rides.aggregate([
  { $match: { status: "Completed" } },
  { $group: {
      _id: { month: { $substr: ["$date", 0, 7] }, vehicle: "$vehicle_type" },
      revenue: { $sum: "$booking_value" },
      rides:   { $sum: 1 }
  }},
  { $out: "monthly_revenue" }
])

// Incremental: recompute one month and merge it into the same collection
db.rides.aggregate([
  { $match: { status: "Completed", date: { $gte: "2024-12-01" } } },
  { $group: {
      _id: { month: { $substr: ["$date", 0, 7] }, vehicle: "$vehicle_type" },
      revenue: { $sum: "$booking_value" },
      rides:   { $sum: 1 }
  }},
  { $merge: {
      into: "monthly_revenue",
      on: "_id",
      whenMatched: "replace",
      whenNotMatched: "insert"
  }}
])

Why this pattern matters commercially. A dashboard that recomputes a twelve-month revenue series on every page load re-reads the whole collection every time. Materialise the summary once a night and the dashboard reads twelve small documents instead of fifteen thousand large ones. The query cost stops scaling with the size of the raw data and starts scaling with the size of the answer — which is the entire idea behind a pre-aggregated reporting layer.

Lab Task — Before You Leave

1

Run db.rides.find({ "route.pickup": "Connaught Place" }).explain("executionStats") and record stage, nReturned, totalDocsExamined and executionTimeMillis.

2

Create db.rides.createIndex({ "route.pickup": 1, status: 1 }), re-run, and record the same four values. Calculate the examined-to-returned ratio before and after.

3

Design and justify an ESR-correct compound index for: completed Premier Sedan rides over 20 km, sorted by booking value descending. Prove it with explain().

4

Run all seven pipelines from Section 7 and confirm your numbers match the tables above. Any mismatch is a typo — find it. Then re-sort the hourly pipeline by failure_rate_pct with no volume guard, observe 03:00 at 43.48% on 46 bookings, and add { $match: { bookings: { $gte: 1000 } } } to repair the ranking.

5

Materialise two summary collections with $out: monthly_revenue and hourly_demand. Lecture 15 builds charts directly on top of them.

Key Takeaways

✓ What You Should Be Able to Say After This Session

  1. An unindexed query is O(n). A COLLSCAN reads every document, so cost grows in lockstep with the collection. A B-tree index turns that into an IXSCAN at roughly O(log n) — growth stops mattering.
  2. Field order in a compound index is decided by ESR — Equality, then Sort, then Range — because a range selects a span, and anything indexed after a span is no longer in sorted order.
  3. Judge a query by totalDocsExamined ÷ nReturned, not by milliseconds. 1:1 is perfect; a covered query examines zero documents. Milliseconds flatter a warm cache.
  4. Indexes are not free. Every write pays for every index, and indexes compete with documents for RAM. Index the queries you run, measure with explain(), and drop whatever $indexStats shows unused.
  5. An aggregation pipeline is an ordered conveyor belt of stages, mapping almost one-to-one onto SQL: $match≈WHERE, $group≈GROUP BY, $sort≈ORDER BY, $project≈SELECT, $lookup≈LEFT JOIN.
  6. $match and $limit go as early as possible — partly because later stages then handle fewer documents, but decisively because only a leading $match can use an index. One $match belongs late, though: a minimum-volume guard after $group, because a rate ranked without a denominator sends you chasing noise — 03:00 fails 43.48% of the time, on 46 bookings all year. Materialise expensive results with $out or $merge so dashboards read answers, not raw data.

→ Next: Lecture 15 — Atlas Charts & the Uber Analytics Dashboard

Everything you computed today becomes something a stakeholder can look at. We connect Atlas Charts to bdma.rides and to the monthly_revenue and hourly_demand collections you just materialised, and build the full dashboard: the KPI header from your $facet, the status breakdown, the bimodal demand curve with its 17:00 failure spike, and pickup-point cancellation rates. The aggregation pipelines are the data layer; Lecture 15 is the presentation layer. Bring working pipelines — the charts are only as good as the queries underneath them.