Aggregation Deep Dive

MongoDB Intermediate/Advanced — Aggregation Deep Dive
MongoDB Intermediate/Advanced
Chapter 2 · Aggregation Deep Dive

🧩 Aggregation Deep Dive

Chapter 1 built pipelines from $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.

db.orders.aggregate([ { $lookup: { from: "products", localField: "product_id", foreignField: "_id", as: "product_details" } }, { $unwind: "$product_details" } ])

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.

⚠️ Gotcha: $unwind Drops Documents With an Empty or Missing Array by Default

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.

db.products.aggregate([ { $match: { category: "electronics" } }, { $facet: { paginatedResults: [ { $sort: { price: -1 } }, { $skip: 0 }, { $limit: 10 } ], totalCount: [ { $count: "count" } ] } } ])

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.

db.products.aggregate([ { $bucket: { groupBy: "$price", boundaries: [0, 50, 100, 200, 500], default: "500+", output: { count: { $sum: 1 }, products: { $push: "$name" } } } } ])

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.

db.orders.aggregate([ { $match: { status: "completed" } }, { $facet: { priceHistogram: [ { $bucket: { groupBy: "$total", boundaries: [0, 25, 50, 100, 250], default: "250+", output: { count: { $sum: 1 } } } } ], grandTotal: [ { $group: { _id: null, sum: { $sum: "$total" } } } ] } } ])

One aggregation call, two independent analytical results — the kind of thing that would otherwise mean two separate SQL queries against the same table.

StagePurposeClosest SQL Equivalent
$unwindTurn one array field into many documentsA join against a comma-split/normalized child table
$facetRun parallel sub-pipelines, one input, several outputsSeveral separate queries, or a CTE reused twice
$bucketGroup by value range, not exact valueCASE 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.

→ Solution

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.

→ Solution

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.

→ Solution

⚠️ Gotcha: $facet Runs Every Sub-Pipeline in Memory, With No Index Help Past the First Stage

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.