Making queries fast, and turning 15,000 raw bookings into the handful of numbers a management meeting actually needs
explain("executionStats")O(n) and quantify what that costs as a collection grows from thousands to millions of documentsexplain("executionStats") and judge a query by the ratio of totalDocsExamined to nReturned$match, $group, $project, $unwind, $lookup and $facetrides collection, guard rate-based rankings with a minimum-volume $match, and materialise the answers with $merge for Lecture 15| Time | Segment | Activity |
|---|---|---|
| 0–5 min | Recap | Lecture 13's query operators — now make them fast, then make them analytical |
| 5–15 min | The Scan Problem | COLLSCAN demo on rides; the arithmetic at 15,000 vs 15,000,000 documents |
| 15–28 min | Index Types | createIndex(), direction, single-field, compound, multikey, text, TTL |
| 28–40 min | The ESR Rule | Compound field ordering worked through on a rides query |
| 40–56 min | explain() Lab | Read executionStats; build a covered query; discuss index cost and Performance Advisor |
| 56–70 min | Pipeline Model | Stages as a conveyor belt; the SQL mapping; accumulators |
| 70–84 min | Pipeline Lab | Seven business questions answered against bdma.rides |
| 84–90 min | Wrap-Up | $out/$merge, and how Lecture 15 consumes these results |
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" })
O(n), and Why That Matters CommerciallyThe 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 size | Documents read per query | Rough response time | Business reality |
|---|---|---|---|
| 15,000 (our dataset) | 15,000 | ~20–40 ms | Feels instant. The problem is completely hidden. |
| 1,500,000 (100×) | 1,500,000 | ~2–4 s | A dashboard tile that visibly stalls on load |
| 15,000,000 (1,000×) | 15,000,000 | ~20–40 s | Request 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.
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.
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.
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.
| Type | Created with | What it is for | Example on rides |
|---|---|---|---|
| Single-field | { status: 1 } | Filtering or sorting on one field | Every "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 element | A surge_flags: ["rain","peak"] array |
| Text | { "cancellation.reason": "text" } | Word-level search inside free text, with stemming | Searching cancellation reasons by keyword |
| TTL | { createdAt: 1 }, { expireAfterSeconds: … } | Automatic deletion of documents after an interval | Expiring driver-location pings after 24 hours |
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.
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.
// 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 } } }
)
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.
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 on | Index usable? |
|---|---|
status | Yes — left prefix |
status + vehicle_type | Yes — left prefix |
status + vehicle_type + booking_value | Yes — the whole index |
vehicle_type alone | No — skips the leading field |
booking_value alone | No — 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.
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.
ridesThe 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 order | What happens | Verdict |
|---|---|---|
{status, vehicle_type, date, booking_value} | Equality narrows to Completed + Go Sedan; the block is already ordered by date; the range is applied last | ESR — 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 appears | Range before sort — slow |
{booking_value, status, vehicle_type, date} | Leads with a range: the scan starts broad and stays broad | Worst 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.
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")
| Field | Meaning | What you want to see |
|---|---|---|
stage | The access method: COLLSCAN, IXSCAN, FETCH, SORT, PROJECTION_COVERED | IXSCAN. Any bare COLLSCAN on a large collection is a finding. A SORT stage means the sort was done in memory. |
nReturned | Documents the query actually returned | Your denominator — this is the useful work |
totalDocsExamined | Documents the engine had to read | As close to nReturned as possible |
totalKeysExamined | Index entries read | Also close to nReturned; much higher means a poorly ordered index |
executionTimeMillis | Wall-clock time for the query | The 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 — 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.
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.
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.
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.
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.
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.
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.
Prefer one well-ordered compound index to several single-field ones. The prefix rule means one index often serves a family of queries.
Measure before and after with explain(). If the examined-to-returned ratio did not improve, the index was the wrong one — drop it.
Review $indexStats quarterly and drop what nothing uses. Index sprawl accumulates silently.
Build on a live system with care. Index builds consume CPU and I/O; on a replica set, build during a low-traffic window.
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).
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.
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.
You already know this material in another notation. The translation is close to exact.
| SQL | Aggregation stage | Note |
|---|---|---|
WHERE | $match | Same operators as find(). Can use an index if it runs first. |
GROUP BY | $group | The grouping key is always _id |
HAVING | $match after $group | Same stage, different position — position is the meaning |
SELECT col, expr | $project / $addFields | $project replaces the shape; $addFields adds to it |
ORDER BY | $sort | 1 ascending, -1 descending |
LIMIT / OFFSET | $limit / $skip | Order matters: $skip then $limit |
LEFT OUTER JOIN | $lookup | Always a left outer join; results arrive as an array |
COUNT(*) | $count or { $sum: 1 } | $sum: 1 inside $group counts per group |
| no clean equivalent | $unwind | Explodes an array into one document per element |
| several queries | $facet | Runs multiple sub-pipelines over one input in a single pass |
| Stage | Does | On rides |
|---|---|---|
$match | Filters documents | { status: "Completed" } — drops 5,657 documents |
$group | Collapses documents into one per key, applying accumulators | Revenue per vehicle_type |
$project | Chooses and computes the output fields | Keep booking_id and a computed fare_per_km |
$addFields / $set | Adds computed fields, keeping everything else (identical stages) | Add a cancellation_rate_pct after $group |
$sort | Orders the stream | { revenue: -1 } |
$limit / $skip | Truncates / offsets the stream | Top 10 pickups; pagination |
$unwind | One input document with an n-element array becomes n documents | Splitting an array of surge flags or stops |
$lookup | Left outer join to another collection | Enrich rides with a customers collection |
$count | Replaces the stream with a single count document | How many rides exceeded ₹1,000 |
$facet | Runs several sub-pipelines on the same input simultaneously | A whole KPI header row in one query |
$bucket | Groups into explicit numeric boundaries — a histogram | Fare bands: 0–100, 100–250, 250–500, 500+ |
Accumulators are the expressions used inside $group to reduce many documents to one value.
| Accumulator | Returns | Typical use here |
|---|---|---|
$sum | Total; { $sum: 1 } counts | Total revenue, booking counts |
$avg | Mean, ignoring missing values | Average fare ₹235.73, average distance 10.87 km |
$min / $max | Smallest / largest | Cheapest and dearest ride in a group |
$push | Array of all values, duplicates kept | Every reason recorded for a pickup point |
$addToSet | Array of distinct values | Which vehicle types serve a given pickup |
$first / $last | First / last value in the group | Earliest 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.
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.
Every pipeline below runs against bdma.rides and produces the stated answer. Type them; do not paste them.
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 type | Bookings | Completion rate | Revenue | Avg fare |
|---|---|---|---|---|
| Premier Sedan | 1,954 | 62.13% | ₹5,36,855 | ₹442.22 |
| Go Sedan | 2,854 | 61.77% | ₹4,91,473 | ₹278.77 |
| Uber XL | 1,231 | 62.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.
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" }
}}
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 }
])
| Hour | Bookings | Failure rate |
|---|---|---|
| 18:00 | 1,373 | 37.14% |
| 19:00 | 1,246 | 35.96% |
| 09:00 | 1,172 | 36.18% |
| 08:00 | 1,076 | 36.71% |
| 17:00 | 1,062 | 39.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.
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 }
| Hour | Bookings | Failure rate | Is this a finding? |
|---|---|---|---|
| 03:00 | 46 | 43.48% | No — 46 bookings. Twenty failures instead of nineteen moves this figure by more than two points. |
| 07:00 | 637 | 41.60% | Worth a look, but a third of 17:00's volume |
| 16:00 | 764 | 40.45% | Worth a look — and it sits directly before 17:00 |
| 17:00 | 1,062 | 39.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.
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 point | Bookings | Cancellation rate |
|---|---|---|
| Noida Sector 62 | 1,209 | 33.50% |
| New Delhi Railway Station | 1,179 | 33.16% |
| Connaught Place | 1,139 | 32.84% |
| Indira Gandhi International Airport | 1,183 | 32.80% |
| Gurgaon Cyber City | 1,178 | 31.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.
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 by | Reason | Share of that side |
|---|---|---|
| Driver (2,698) | Customer related issue | 27.02% |
| More than permitted people | 24.68% | |
| Personal & Car related issues | 24.17% | |
| Customer coughing/sick | 24.13% | |
| Customer (1,022) | Driver not moving toward pickup | 20.65% |
| AC not working | 20.55% | |
| Driver asked to cancel | 20.35% | |
| Change of plans | 19.86% | |
| Wrong address | 18.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.
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.
$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.
$lookupOur 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.
$match and $limit as Early as PossibleEvery 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.
$match first, on indexed fields, as narrowly as the question allows.
$project away fields you will not use, before any $group or $lookup. Less data moving through each stage means less memory per stage.
$sort + $limit together when you need a top-N, so the engine can apply its top-N optimisation.
Place $lookup late and on an indexed foreignField, after the stream has been narrowed.
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.
$out and $mergeBoth stages must be last in the pipeline, and both write the output to a collection instead of returning it to the client.
$out | $merge | |
|---|---|---|
| Effect | Replaces the target collection entirely | Inserts, updates or merges document by document |
| Use when | A full nightly rebuild | Incremental refresh of a rolling summary |
| Indexes on target | Dropped and lost | Preserved |
| Risk | Destructive — it will overwrite a populated collection without warning | Safe, 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.
Run db.rides.find({ "route.pickup": "Connaught Place" }).explain("executionStats") and record stage, nReturned, totalDocsExamined and executionTimeMillis.
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.
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().
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.
Materialise two summary collections with $out: monthly_revenue and hourly_demand. Lecture 15 builds charts directly on top of them.
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.totalDocsExamined ÷ nReturned, not by milliseconds. 1:1 is perfect; a covered query examines zero documents. Milliseconds flatter a warm cache.explain(), and drop whatever $indexStats shows unused.$match≈WHERE, $group≈GROUP BY, $sort≈ORDER BY, $project≈SELECT, $lookup≈LEFT JOIN.$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.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.