MongoDB Mastery
MongoDB Mastery lessons / Module 11: Advanced Aggregation

$facet, $bucket, window functions, $merge and $out

Level 2: Intermediate25 min

Multi-faceted search results, histograms, running totals and rankings, and materialized views that refresh incrementally.

This lesson is interactive. Run every example against an in-browser MongoDB, see explain() plans drawn as trees, and take the quiz.
Open the interactive lesson
You will learn to
  • Compute several aggregations in one pass with $facet
  • Build histograms with $bucket and $bucketAuto
  • Use $setWindowFields for running totals, moving averages and ranks
  • Materialize results with $merge (incremental) or $out (replace)

conceptThe concept

  • $facet: run several sub-pipelines on the same input and return one document with each result. Typical for search pages (results + counts per category + price ranges).
  • $bucket (explicit boundaries) and $bucketAuto (N evenly filled buckets): histograms.
  • $setWindowFields (5.0+): window functions over partitions sorted by a field: $sum with window: { documents: ["unbounded", "current"] } for running totals, $avg over a range for moving averages, $rank, $denseRank, $shift.
  • $merge: write results into a collection, inserting, replacing or merging per document; can target the same or another database and run incrementally.
  • $out: replace a collection atomically with the results.

whyWhy it matters

These stages replace application-side loops and separate batch jobs. $merge in particular lets you maintain pre-aggregated summary collections (an on-demand materialized view) so dashboards read 30 documents instead of aggregating millions on every page load.

Use caseReal-world scenario

A nightly job computes yesterday's revenue per product and $merges it into daily_product_sales keyed by { day, productId }. The dashboard reads that small collection. Re-running the job for the same day replaces the same keys, so it is idempotent.

Try itExamples

Run and edit these in the interactive lesson.

$facet for a search results page
db.products.aggregate([
  { $match: { rating: { $gte: 4 } } },
  { $facet: {
      results: [{ $sort: { rating: -1 } }, { $limit: 3 }, { $project: { name: 1, price: 1, rating: 1 } }],
      byCategory: [{ $sortByCount: "$category" }],
      priceStats: [{ $group: { _id: null, min: { $min: "$price" }, max: { $max: "$price" }, avg: { $avg: "$price" } } }]
  } }
])
Histogram with $bucket
db.orders.aggregate([
  { $bucket: { groupBy: "$total", boundaries: [0, 100, 250, 500, 1000, 2500], default: "2500+", output: { orders: { $sum: 1 } } } }
])
Running total and rank with $setWindowFields
db.orders.aggregate([
  { $match: { createdAt: { $gte: ISODate("2026-09-01") }, status: { $ne: "cancelled" } } },
  { $group: { _id: { $dateTrunc: { date: "$createdAt", unit: "day" } }, revenue: { $sum: "$total" } } },
  { $setWindowFields: { sortBy: { _id: 1 }, output: {
      runningTotal: { $sum: "$revenue", window: { documents: ["unbounded", "current"] } },
      movingAvg3d: { $avg: "$revenue", window: { documents: [-2, 0] } },
      rank: { $rank: {} } } } },
  { $limit: 7 }
])
$rank here ranks by date because the window is sorted by _id; sort by revenue to rank the best days.
Materialize with $merge
db.orders.aggregate([
  { $match: { status: "delivered" } },
  { $group: { _id: "$customerId", orders: { $sum: 1 }, spend: { $sum: "$total" }, lastOrder: { $max: "$createdAt" } } },
  { $merge: { into: "customer_stats", on: "_id", whenMatched: "replace", whenNotMatched: "insert" } }
])
db.customer_stats.find().sort({ spend: -1 }).limit(3)

internalsUnder the hood

$facet sub-pipelines cannot use indexes (only the stages before $facet can), and the single output document must fit in 16 MB. $setWindowFields requires sorting each partition; with a large unbounded window it may spill to disk. $out writes to a temporary collection and renames it at the end, so readers see either the old or the new data. $merge needs a unique index on its on fields.

Avoid / preferDo this, not that

Avoid
db.orders.aggregate([ /* today's totals */ ..., { $out: "daily_sales" }])
Prefer
db.orders.aggregate([ /* today's totals */ ...,
  { $merge: { into: "daily_sales", on: "_id", whenMatched: "replace", whenNotMatched: "insert" } }])

$out replaces the entire target collection, so refreshing one day rebuilds all of history. $merge upserts just the rows the pipeline produced.

Avoid
db.orders.aggregate([{ $facet: {
  byStatus: [{ $sortByCount: "$status" }],
  recent: [{ $sort: { createdAt: -1 } }]
} }])
Prefer
db.orders.aggregate([
  { $match: { createdAt: { $gte: ISODate("2026-09-01") } } },
  { $facet: {
    byStatus: [{ $sortByCount: "$status" }],
    recent: [{ $sort: { createdAt: -1 } }, { $limit: 10 }]
  } }
])

Every facet sees every document that reaches $facet, stages inside it cannot use indexes, and all results return in one 16 MB document. Filter first and limit inside each facet.

PitfallsCommon mistakes

  • Using $facet as the first stage on a big collection, expecting each facet to use an index.
  • Re-aggregating raw data on every dashboard request instead of maintaining a summary with $merge.
  • Using $out when you meant to update incrementally; $out replaces the whole target collection.

LimitsWhen not to use it

$facet output is a single document and cannot exceed 16 MB. $out cannot write to a sharded collection; use $merge for that. For dashboards that refresh constantly, maintain a summary collection with $merge instead of re-aggregating raw data on every page load.

SecuritySecurity notes

$out and $merge need write privileges on the target, possibly in another database. A read-only analyst role should not have them, or a report query can overwrite production data.

ChallengeTry it yourself

Bucket products by price into 0 to 50, 50 to 200, 200 to 500 and 500+, showing the number of products and the average rating in each bucket.

Show one solution
One solution
db.products.aggregate([
  { $bucket: {
    groupBy: "$price",
    boundaries: [0, 50, 200, 500],
    default: "500+",
    output: { products: { $sum: 1 }, avgRating: { $avg: "$rating" } }
  } },
  { $set: { avgRating: { $round: ["$avgRating", 2] } } }
])

InterviewQuestions you should be able to answer

How do you build a materialized view in MongoDB?

An aggregation ending in $merge into a summary collection, run on a schedule or triggered by change streams. on defines the key (backed by a unique index), whenMatched chooses replace/merge/keepExisting/pipeline, and incremental runs only process new data (filter by a high-water mark). Standard views (createView) are not materialized; they run the pipeline on every read.

What are window functions used for?

$setWindowFields computes values over a sorted window of related documents without collapsing them like $group: running totals, moving averages, rank within a partition, gaps between consecutive events ($shift), time-weighted averages with range windows on dates.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.