Aggregation Deep Dive
🧩 Aggregation Deep Dive
$match, $group, $project, $sort, and $lookup — enough to answer most "one grouped total" questions. This chapter adds three stages built for genuinely analytical work: $unwind, $facet, and $bucket — MongoDB's spiritual counterpart to mysql_advanced_05's CTEs and mysql_advanced_06's window functions, for the same reason: single queries that used to need several round trips or awkward application-side code.
$unwind — Deconstructing Arrays Into Documents
Chapter 1's $lookup attaches matches as an array field, even when there's exactly one match (Challenge 3 flagged this directly). $unwind takes an array field and outputs one separate document per array element — the reverse of grouping. If an order's product_details array has one element, $unwind turns it into a plain object; if a document had an array of three tags, $unwind would turn one document into three, each with a different single tag.
After this pipeline, each order document has a plain product_details object instead of a one-element array — much easier for application code (or a later $group) to work with directly via dot notation like product_details.name.
If an order's product_id didn't match anything, $lookup attaches an empty array — and $unwind's default behavior is to drop a document entirely when the array it's unwinding is empty or missing, since there's no element to "become" the new document. That silently removes otherwise-valid orders from the pipeline's output. To keep them (with product_details set to null instead), pass the object form: { $unwind: { path: "$product_details", preserveNullAndEmptyArrays: true } }.
$facet — Multiple Result Sets, One Round Trip
A common real-world need: return a page of results and a total count of all matching documents, for pagination — normally two separate queries. $facet runs several independent sub-pipelines against the same input documents in a single aggregation call, each sub-pipeline's output landing in its own named array in one result document.
The output is one document with two fields: paginatedResults (an array of up to 10 products) and totalCount (an array holding one document like { count: 143 }). Both sub-pipelines started from the same $match-filtered set, computed independently, without either one affecting the other.
$bucket — Grouping Into Ranges
$group groups by an exact value shared across documents. $bucket groups by which range a value falls into — a built-in histogram, the kind of grouping SQL usually has to fake with a CASE WHEN price < 50 THEN '0-50' ... expression inside a subquery before it can GROUP BY that computed label.
boundaries defines the range edges (each bucket is [inclusive, exclusive)), and default catches anything falling outside all of them — here, anything 500 or above. output works exactly like $group's accumulators, just applied per bucket instead of per exact value.
$group vs. $bucket
$group
Groups documents that share the exact same value — every order with customer_id: "C104" in one group.
$bucket
Groups documents whose value falls into the same range — every product priced $50–$99.99 in one group, regardless of the exact price.
Combining All Three: A Real Analytical Pipeline
A dashboard query: for completed orders, get a price-range histogram AND a grand total, in one call.
One aggregation call, two independent analytical results — the kind of thing that would otherwise mean two separate SQL queries against the same table.
| Stage | Purpose | Closest SQL Equivalent |
|---|---|---|
$unwind | Turn one array field into many documents | A join against a comma-split/normalized child table |
$facet | Run parallel sub-pipelines, one input, several outputs | Several separate queries, or a CTE reused twice |
$bucket | Group by value range, not exact value | CASE WHEN + GROUP BY on the computed label |
💻 Coding Challenges
Challenge 1: Unwind a Tags Array
Given articles with a tags: ["mongodb", "database", "nosql"] array field, write a pipeline that unwinds tags, then groups by tag to count how many articles use each one.
Goal: Practice the unwind-then-group pattern for counting values inside an array field, not just a top-level field.
Challenge 2: Build a $facet Dashboard Query
Write a single aggregation on a reviews collection that returns both the average rating ($avg) and the 3 most recent reviews (sorted by createdAt descending, limited to 3) in one call.
Goal: Practice structuring two genuinely different sub-pipelines inside one $facet stage.
Challenge 3: Design a $bucket Age Histogram
Given a users collection with an age field, write a $bucket stage grouping users into 18-25, 26-35, 36-50, and a catch-all for anything older, counting users in each bucket.
Goal: Practice choosing boundaries and a default bucket correctly, including which edge (upper or lower) each boundary belongs to.
Because $facet's whole point is running several sub-pipelines against the same input simultaneously, MongoDB can't use an index for the sub-pipelines themselves — indexes only help the stage that produces $facet's input in the first place. Always put a $match (and $sort, if applicable to an existing index) before $facet, exactly as in this chapter's combined example, so every sub-pipeline starts from an already-small, already-filtered set rather than the full collection.
🎯 What's Next
The next chapter is Schema Design Patterns at Scale — the embedding-vs-referencing question from mongodb1-5 revisited for large, real applications: the polymorphic pattern, the bucket pattern, the computed pattern, and when embedding breaks down enough that it's time to refactor toward references.