Making queries fast β then turning 15,000 raw bookings into the handful of numbers a management meeting actually needs.
Lecture 13 gave you find() and the query operators. Every query you wrote worked. None of them were fast β the collection was simply small enough to hide it.
Why an unindexed query is O(n), how a B-tree fixes it, the ESR rule for compound indexes, and how to read explain() β plus why more indexes is not better.
Pipelines as a conveyor belt of stages. Seven real business questions answered against bdma.rides, and the output materialised for Lecture 15's dashboard.
With no usable index, db.rides.find({ "route.pickup": "Connaught Place" }) reads all 15,000 documents to return ~1,139. Plan stage: COLLSCAN β and it is O(n), with no early exit.
| Collection size | Docs read per query | Response | Business reality |
|---|---|---|---|
| 15,000 (today) | 15,000 | ~30 ms | Feels instant β the problem is invisible |
| 1,500,000 | 1,500,000 | ~3 s | A dashboard tile that visibly stalls |
| 15,000,000 | 15,000,000 | ~30 s | Timeouts. The report is abandoned, not slow. |
Read the whole book to find one sentence. Twice the data, twice the work β and every document dragged through RAM evicts useful data from cache, slowing unrelated queries too.
A sorted B-tree of values plus pointers: read the index page, turn to page 412, read one sentence. Finding one doc among 15 million costs about twice as much as among 15 thousand β not a thousand times.
createIndex() β and when direction mattersdb.rides.createIndex({ status: 1 }) // 1 = ascending
db.rides.createIndex({ booking_value: -1 }) // -1 = descending
db.rides.createIndex({ "route.pickup": 1 }, { name: "pickup_idx" })
db.rides.getIndexes()
db.rides.dropIndex("pickup_idx")
For a single-field index, direction is almost irrelevant β a B-tree walks either way. It becomes decisive for a compound sort, which an index serves only if the sort matches it exactly, or is its exact mirror.
db.rides.createIndex({ vehicle_type: 1, booking_value: -1 })
.sort({ vehicle_type: 1, booking_value: -1 }) // β
exact match
.sort({ vehicle_type: -1, booking_value: 1 }) // β
exact mirror
.sort({ vehicle_type: 1, booking_value: 1 }) // β in-memory SORT
| Type | Created with | For | On rides |
|---|---|---|---|
| Single-field | { status: 1 } | Filter or sort on one field | Every "completed only" query |
| Compound | { status: 1, date: 1 } | Several fields at once; serves prefixes too | "Completed rides in March, newest first" |
| Multikey | { tags: 1 } | Automatic on arrays β one entry per element | A surge_flags array |
| Text | { "cancellation.reason": "text" } | Word search with stemming; one per collection | Keyword search of cancel reasons |
| TTL | { expireAfterSeconds: 86400 } | Auto-delete after an interval; needs a real Date | Expiring driver-location pings |
Also: unique (natural for booking_id), partial β ideal here, since booking_value is absent on ~5,000 cancelled bookings β and 2dsphere for "drivers within 3 km".
Given { status: 1, vehicle_type: 1, booking_value: -1 }, the index serves any query using a left prefix of its fields:
| Query filters on | Usable? |
|---|---|
status Β· status + vehicle_type Β· all three | β Yes |
vehicle_type alone Β· booking_value alone | β No β skips a leading field |
E exact matches first Β· S sort fields next Β· R range operators ($gt, $lt, $in over a span) last.
Why: equality narrows the B-tree to one contiguous block, inside which entries are still ordered by the next field β so the sort is free. A range selects a span, so anything after it is no longer in one sorted run and must be re-sorted in memory.
ridesdb.rides.find({
status: "Completed", // E β equality
vehicle_type: "Go Sedan", // E β equality
booking_value: { $gt: 500 } // R β range
}).sort({ date: -1 }) // S β sort
db.rides.createIndex({ status: 1, vehicle_type: 1, date: -1, booking_value: 1 })
// βββ E βββ βββββ E ββββββ ββ S ββ ββββββ R ββββββ
| Field order | What happens | Verdict |
|---|---|---|
status, vehicle_type, date, booking_value | Equality narrows the block; it is already ordered by date; range applied last | ESR β |
status, vehicle_type, booking_value, date | Range fans the scan, so date loses its order β an in-memory SORT appears | Slow |
booking_value, status, vehicle_type, date | Leads with a range: the scan starts broad and stays broad | Worst |
IXSCAN. "The index is being used" is not evidence the index is good. Only ESR gets docs-examined close to docs-returned.explain("executionStats") β four numbersdb.rides.find({ "route.pickup": "Connaught Place" })
.explain("executionStats")
| Field | Meaning | What you want |
|---|---|---|
stage | COLLSCAN / IXSCAN / FETCH / SORT | IXSCAN. A SORT stage means an in-memory sort. |
nReturned | Documents actually returned | Your denominator β the useful work |
totalDocsExamined | Documents the engine had to read | As close to nReturned as possible |
executionTimeMillis | Wall-clock time | The headline β and the least trustworthy number |
totalDocsExamined Γ· nReturned. 1:1 is perfect. 10:1 is tolerable. 1,000:1 means the database did a thousand times more work than the answer needed β and it scales exactly that badly.
stage: "COLLSCAN"
nReturned: 1139
totalDocsExamined: 15000
executionTimeMillis: 28
// createIndex({"route.pickup":1})
stage: "IXSCAN"
nReturned: 1139
totalDocsExamined: 1139
executionTimeMillis: 3
stage: "PROJECTION_COVERED"
nReturned: 1763
totalDocsExamined: 0
A covered query is answered from the index alone β every filtered and returned field lives in the index, so the documents are never touched. Note that _id must be explicitly excluded:
db.rides.createIndex({ status: 1, vehicle_type: 1, booking_value: 1 })
db.rides.find(
{ status: "Completed", vehicle_type: "Go Sedan" },
{ _id: 0, vehicle_type: 1, booking_value: 1 }
)
An insert writes the document plus one entry per index. Twelve indexes β thirteen units of write work. The analytics team's indexes are paid for, permanently, by the booking ingest path.
Your working set is the data + indexes queries touch regularly. Fits in RAM β fast. Doesn't β disk reads, often 50Γ slower. Indexes compete with documents for that same cache.
They still slow writes and still occupy RAM, while contributing nothing.
db.rides.aggregate([{ $indexStats: {} }])
// accesses.ops: 0 β drop candidate
Atlas Performance Advisor (Clusters β Performance Advisor) profiles real traffic and ranks index recommendations by expected impact, flagging unused and redundant ones too.
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
])
// β Premier Sedan βΉ5,36,855 Β· Go Sedan βΉ4,91,473 Β· Uber XL βΉ4,31,927
| SQL | Stage | SQL | Stage |
|---|---|---|---|
WHERE | $match | ORDER BY | $sort |
GROUP BY | $group (key is always _id) | LIMIT / OFFSET | $limit / $skip |
HAVING | $match after $group | LEFT OUTER JOIN | $lookup |
SELECT col, expr | $project / $addFields | COUNT(*) | $count or { $sum: 1 } |
HAVING and WHERE are the same stage β only the position differs, and the position is the meaning.$match | Filter the stream |
$group | Collapse to one doc per key |
$project | Choose / compute output fields |
$addFields / $set | Add fields, keep the rest |
$unwind | Array of n β n documents |
$lookup | Left outer join β array |
$count / $bucket | Count Β· numeric histogram |
$facet | Many sub-pipelines, one pass |
$group)$sum | Total; $sum: 1 counts |
$avg | Mean β ignores missing |
$min / $max | Extremes |
$push | All values, duplicates kept |
$addToSet | Distinct values only |
$first / $last | Sort first, or it is arbitrary |
$avg trap: it skips missing fields rather than treating them as zero. So $avg: "$booking_value" over all 15,000 rides silently returns the average completed fare (βΉ235.73). Make the population explicit with $match.$match and $limit as early as possibledb.rides.aggregate([
{ $group: { _id: "$vehicle_type",
revenue: { $sum: "$booking_value" }}},
{ $match: { _id: "Go Sedan" } }
])
// groups all 15,000, discards most
db.rides.aggregate([
{ $match: { vehicle_type: "Go Sedan" } },
{ $group: { _id: "$vehicle_type",
revenue: { $sum: "$booking_value" }}}
])
// groups 2,854 β a fifth of the work
Fewer documents for every later stage is merely arithmetic. The decisive reason is different: only a $match in the first position can use an index. Once any stage has transformed the stream, the documents are no longer the indexed documents on disk β every later $match is an in-memory filter over intermediate results.
Same logic for $sort: leading $sort β index-served. After $group β in-memory, against a hard 100 MB stage limit. $sort + $limit adjacent gets a special top-n optimisation.
db.rides.aggregate([β¦]).explain("executionStats") β check the first stage. IXSCAN is the goal, COLLSCAN is the warning.// Q1 β completion rate by vehicle type. $cond inside $sum gives you
// numerator and 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 | Bookings | Completion | 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 |
Q6 β average driver rating by vehicle, completed rides only ($match on status + "ratings.driver": { $exists: true }) β lands every type between 4.39 and 4.42, against a fleet average of 4.41.
booked_at ("2024-03-14T18:22:07Z") and date ("2024-03-14") are strings, not BSON Dates. ISO strings sort lexicographically in date order, so substrings work β or convert properly.
// Q2 β revenue by month
db.rides.aggregate([
{ $match: { status: "Completed" } },
{ $group: { _id: { $substr: ["$date", 0, 7] }, // "2024-03"
revenue: { $sum: "$booking_value" },
rides: { $sum: 1 } }},
{ $sort: { _id: 1 } }
])
// β Mar strongest βΉ1,99,972 / 859 rides Β· Jun weakest βΉ1,66,260
// twelve months sum to the βΉ22,02,449 of completed value
// Q3 β busiest hour of day
db.rides.aggregate([
{ $group: { _id: { $toInt: { $substr: ["$booked_at", 11, 2] } },
bookings: { $sum: 1 } }},
{ $sort: { bookings: -1 } }, { $limit: 5 }
])
// The convert-first alternative:
{ $hour: { $dateFromString: { dateString: "$booked_at" } } }
{ $dateToString: { format: "%Y-%m",
date: { $dateFromString: { dateString: "$booked_at" } } } }
| Hour | Bookings | Fail % |
|---|---|---|
| 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% |
39.92%
worst failure rate among the hours that carry volume
Higher than the 18:00 peak's 37.14% β even though 18:00 takes 1,373 bookings to 17:00's 1,062. The evening surge begins at 17:00, and that is exactly where completions collapse.
| 03:00 | 46 | 43.48% |
| 07:00 | 637 | 41.60% |
| 16:00 | 764 | 40.45% |
46 bookings all year β one every eight days. One extra failure moves that rate two points. It is noise, and it would have you staffing drivers at 3 a.m.
{ $match: { bookings: { $gte: 1000 } } }
// β guard, after $group, before ranking
// Q4 β cancellation rate by pickup. $match AFTER $group = SQL's HAVING:
// a rate on a thin sample is noise, so guard with a volume threshold.
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] }}
}},
{ $match: { bookings: { $gte: 1000 } } },
{ $addFields: { cancellation_rate_pct: { $round: [
{ $multiply: [{ $divide: ["$cancelled","$bookings"] }, 100] }, 2 ]}}},
{ $sort: { cancellation_rate_pct: -1 } }, { $limit: 10 }
])
| Noida Sector 62 | 1,209 | 33.50% |
| New Delhi Rly Stn | 1,179 | 33.16% |
| Connaught Place | 1,139 | 32.84% |
| IGI Airport | 1,183 | 32.80% |
| Gurgaon Cyber City | 1,178 | 31.24% |
Driver (2,698): customer related issue 27.02% Β· more than permitted people 24.68% Β· personal & car 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%
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" } }}
],
by_status: [ { $group: { _id: "$status", n: { $sum: 1 } } },
{ $sort: { n: -1 } } ],
payment_mix: [ { $match: { payment_method: { $exists: true } } },
{ $group: { _id: "$payment_method", n: { $sum: 1 } } } ],
fare_bands: [ { $match: { booking_value: { $exists: true } } },
{ $bucket: { groupBy: "$booking_value",
boundaries: [0,100,250,500,1000], default: "1000+",
output: { rides: { $sum: 1 } } } } ]
}}])
// headline β 15,000 bookings Β· 9,343 completed Β· 62.29% Β· βΉ22,02,449 Β· 10.87 km
// payment_mix β UPI 45.03% Β· Cash 25.31% Β· Wallet 11.79% Β· Credit 10.79% Β· Debit 7.08%
Then write the answer down once, so the dashboard reads results instead of raw data:
{ $out: "monthly_revenue" } // full rebuild, destructive
{ $merge: { into: "monthly_revenue", on: "_id",
whenMatched: "replace", whenNotMatched: "insert" } } // incremental, safe
$facet sub-pipeline cannot use an index β the stream is already read. Put any index-eligible $match before the $facet, never only inside it.O(n). COLLSCAN reads everything; a B-tree IXSCAN is ~O(log n), so growth stops mattering.totalDocsExamined Γ· nReturned, not milliseconds. 1:1 is perfect; a covered query examines zero documents.$indexStats shows unused.$matchβWHERE, $groupβGROUP BY, $sortβORDER BY, $projectβSELECT, $lookupβLEFT JOIN.$match and $limit go first β later stages then do less work, and only a leading $match can use an index. Materialise with $out/$merge.$facet becomes the KPI header, the hourly pipeline becomes the bimodal demand curve with its 17:00 spike, and monthly_revenue drives the trend line. Bring working pipelines β the charts are only as good as the queries underneath.