mongodb-aggregation-stages-deep

MongoDB Aggregation Stages — Deep Reference

This skill is the deep-dive companion to mongodb-aggregation-pipeline. It covers the highest-impact stages where most aggregation bugs and performance problems hide: cross-collection joins, recursive graph traversal, parallel facets, materialized-view writes, window functions, and time-series gap filling. Each section gives syntax, index/memory requirements, working examples, and the trade-offs that decide whether you should reach for the stage at all.

Coverage map:

Stage Purpose Minimum version
$lookup Left outer join — equality, pipeline, sub-search 3.2 (pipeline 3.6, search 6.0)
$graphLookup Recursive traversal of a single collection 3.4
$facet Parallel sub-pipelines over the same input 3.4
$bucket / $bucketAuto Explicit / automatic histogram bucketing 3.4
$merge Insert / update / upsert into any collection 4.2
$out Replace a target collection wholesale 2.6 (cannot target a sharded collection)
$setWindowFields SQL-style window functions over partitions 5.0
$densify Insert synthetic documents to close numeric/time gaps 5.1
$fill Populate null/missing values (constant, linear, LOCF) 5.3
$unionWith UNION ALL across collections / pipelines 4.4

When to use this skill:

When NOT to use this skill:

Cross-link reading list:


1. $lookup — left outer join

$lookup performs an unsharded-style left outer join from the input collection (the “local” side) to a foreign collection. The optimizer picks between four physical operators based on input cardinality, index presence, and pipeline shape:

Operator When
Indexed nested loop foreignField is indexed, equality-style
Non-indexed nested loop No usable index, small foreign side
Hash join (HJ) MongoDB 6.0+, both sides scanned once
Dynamic indexed (DI) Pipeline with $match matching index prefix

explain("executionStats") exposes the choice under $lookup.strategy / eqLookupStrategy. Treat NestedLoopJoin over hundreds of thousands of documents as a red flag — it scans the foreign collection once per input document.

1.1 Equality form (localField/foreignField)

db.orders.aggregate([
  { $lookup: {
      from: "inventory",
      localField: "sku",
      foreignField: "sku",
      as: "stock"
  } }
]);

Rules:

1.2 Pipeline form with let

The pipeline form unlocks correlated subqueries, multi-key joins, projection, sorting, and limiting on the joined side:

db.orders.aggregate([
  { $lookup: {
      from: "inventory",
      let: { orderSku: "$sku", orderQty: "$qty" },
      pipeline: [
        { $match: { $expr: {
            $and: [
              { $eq: ["$sku", "$$orderSku"] },
              { $gte: ["$stock", "$$orderQty"] }
            ]
        } } },
        { $project: { _id: 0, sku: 1, stock: 1, warehouse: 1 } },
        { $limit: 5 }
      ],
      as: "candidates"
  } }
]);

Key points:

1.3 Atlas Search inside $lookup (6.0+)

{ $lookup: {
    from: "products",
    let: { q: "$searchTerm" },
    pipeline: [
      { $search: {
          index: "products_text",
          text: { query: "$$q", path: ["name", "description"] }
      } },
      { $limit: 10 }
    ],
    as: "matches"
} }

$search (and $searchMeta) must be the first stage in the inner pipeline. The from collection must have an Atlas Search index.

1.4 Sharded collections

Before MongoDB 5.1, $lookup could not target a sharded foreign collection. From 5.1 onward, sharded $lookup is supported but the optimizer routes through the primary shard of the join collection for non-equality joins, which can serialize throughput. Strategies:

1.5 $lookup vs denormalization decision tree

Signal Choose
Foreign side is small (< few hundred MB), changes slowly Denormalize / embed
Foreign side is large, mostly read $lookup + index
One-to-many with extreme fan-out (> 16 MB joined doc) Reference + on-demand $lookup
Need search relevance on foreign side $lookup w/ $search
Reporting / dashboards, repeated query Materialize via $merge
Real-time write-heavy with strict consistency Embed (avoid join across collections)

See mongodb-schema-design for the embed-vs-reference framework.

1.6 Common $lookup pitfalls


2. $graphLookup — recursive single-collection traversal

$graphLookup performs a recursive walk over the same (or another) collection, accumulating every reachable document into a single output array. It is MongoDB’s answer to SQL CONNECT BY / recursive CTEs.

2.1 Syntax

db.employees.aggregate([
  { $match: { _id: 1 } },          // start from CEO
  { $graphLookup: {
      from: "employees",
      startWith: "$_id",
      connectFromField: "_id",
      connectToField: "managerId",
      as: "reports",
      maxDepth: 5,
      depthField: "level",
      restrictSearchWithMatch: { active: true }
  } }
]);

2.2 Tree / hierarchy patterns

The classic org-chart query:

// All subordinates beneath an arbitrary employee, with depth.
db.employees.aggregate([
  { $match: { _id: targetEmployeeId } },
  { $graphLookup: {
      from: "employees",
      startWith: "$_id",
      connectFromField: "_id",
      connectToField: "managerId",
      as: "subordinates",
      depthField: "depth"
  } },
  { $project: {
      name: 1,
      subordinates: {
        $sortArray: { input: "$subordinates", sortBy: { depth: 1, name: 1 } }
      }
  } }
]);

2.3 Category trees and bill-of-materials

For category taxonomies, traverse from child to root via parentId:

db.categories.aggregate([
  { $match: { slug: "wireless-headphones" } },
  { $graphLookup: {
      from: "categories",
      startWith: "$parentId",
      connectFromField: "parentId",
      connectToField: "_id",
      as: "ancestry",
      depthField: "depth"
  } }
]);

For bill-of-materials (BOM), the inverse direction reveals all sub-parts:

db.parts.aggregate([
  { $match: { sku: "ASSEMBLY-001" } },
  { $graphLookup: {
      from: "parts",
      startWith: "$components.sku",   // array of child SKUs
      connectFromField: "components.sku",
      connectToField: "sku",
      as: "fullBom",
      maxDepth: 20,
      depthField: "level"
  } }
]);

2.4 Cycle protection

$graphLookup is naturally cycle-safe: it tracks already-visited documents by _id and will not revisit them. However, without maxDepth a deep or wide graph can still allocate huge intermediate state. Cap maxDepth at the deepest meaningful value (org chart: 10–12 levels; permission graph: 4–6 typical).

2.5 Memory and performance

2.6 Common use cases


3. $facet — parallel sub-pipelines

$facet runs multiple independent aggregation sub-pipelines over the same input set in a single stage, returning a single document whose fields are the array results of each branch.

3.1 Syntax and structure

db.products.aggregate([
  { $match: { active: true } },
  { $facet: {
      summary: [
        { $group: { _id: null, total: { $sum: 1 }, avgPrice: { $avg: "$price" } } }
      ],
      byCategory: [
        { $group: { _id: "$category", count: { $sum: 1 } } },
        { $sort: { count: -1 } },
        { $limit: 10 }
      ],
      priceBuckets: [
        { $bucket: {
            groupBy: "$price",
            boundaries: [0, 25, 50, 100, 250, 1000],
            default: "1000+",
            output: { count: { $sum: 1 } }
        } }
      ]
  } }
]);

Output is exactly one document with three fields, each containing the sub-pipeline’s array of result documents.

3.2 Constraints

3.3 When to reach for $facet

Use case Pattern
Faceted search UI (counts by category, brand, etc.) One branch per facet, each a $group over the filtered input
Dashboards with multiple aggregates from one query One branch per tile
Histograms + summary stats together $bucket branch + $group branch
Pagination with total count data branch with $skip/$limit, total branch with $count

The pagination idiom:

db.posts.aggregate([
  { $match: filter },
  { $facet: {
      data: [
        { $sort: { createdAt: -1 } },
        { $skip: page * pageSize },
        { $limit: pageSize }
      ],
      meta: [ { $count: "total" } ]
  } },
  { $project: {
      data: 1,
      total: { $arrayElemAt: ["$meta.total", 0] }
  } }
]);

3.4 Faceted classification example

db.products.aggregate([
  { $match: { active: true, inStock: true } },
  { $facet: {
      byBrand: [
        { $group: { _id: "$brand", count: { $sum: 1 } } },
        { $sort: { count: -1 } },
        { $limit: 20 }
      ],
      byPriceRange: [
        { $bucket: {
            groupBy: "$price",
            boundaries: [0, 50, 100, 200, 500, 1000],
            default: "1000+",
            output: { count: { $sum: 1 }, examples: { $push: "$name" } }
        } }
      ],
      byRating: [
        { $bucketAuto: { groupBy: "$rating", buckets: 5 } }
      ]
  } }
]);

3.5 $facet vs running pipelines in parallel from the client

Because every branch shares the same upstream input, $facet avoids the duplicated work of running N independent pipelines that each repeat the filter and projection stages. Trade-offs:


4. $bucket and $bucketAuto — histograms

Both stages bucket documents into ranges of a numeric (or date) expression and emit one document per bucket.

4.1 $bucket — explicit boundaries

db.products.aggregate([
  { $bucket: {
      groupBy: "$price",
      boundaries: [0, 25, 50, 100, 250, 1000],
      default: "1000+",
      output: {
        count: { $sum: 1 },
        avgRating: { $avg: "$rating" },
        examples: { $push: "$name" }
      }
  } }
]);

Rules:

4.2 $bucketAuto — automatic boundaries

db.products.aggregate([
  { $bucketAuto: {
      groupBy: "$price",
      buckets: 5,
      granularity: "R20",
      output: { count: { $sum: 1 } }
  } }
]);

$bucketAuto distributes documents as evenly as possible across the requested number of buckets. granularity (optional) snaps boundaries to a “preferred number” series:

Granularity Series
R5 Renard 5: 1.0, 1.6, 2.5, 4.0, 6.3
R10 Renard 10: 1.0, 1.25, 1.6, 2.0, …, 8.0
R20 Renard 20: finer-grained Renard
R40/R80 Even finer Renard
1-2-5 1, 2, 5, 10, 20, 50, 100, …
E6/E12/E24/E48/E96/E192 IEC E-series (electronics)
POWERSOF2 1, 2, 4, 8, 16, 32, 64, … — good for byte sizes

Boundary values are multiplied by powers of 10 so they cover the actual data range. Use POWERSOF2 for log-scale histograms (latency, file size). Use Renard for engineering-style “nice number” bin edges.

4.3 When to use which

Need Stage
You know the meaningful break points (price tiers) $bucket
You want equal-population buckets $bucketAuto (no granularity)
You want equal-population buckets on a log scale $bucketAuto + POWERSOF2
You need labeled buckets (“cheap”, “mid”, “premium”) $bucket + post-$project

4.4 Histogram + summary together

Combine with $facet to render a dashboard widget in one round-trip:

db.requests.aggregate([
  { $match: { ts: { $gte: ISODate("2026-05-01") } } },
  { $facet: {
      latencyHistogram: [
        { $bucketAuto: { groupBy: "$durationMs", buckets: 20, granularity: "POWERSOF2" } }
      ],
      summary: [
        { $group: {
            _id: null,
            p50: { $percentile: { input: "$durationMs", p: [0.5], method: "approximate" } },
            p95: { $percentile: { input: "$durationMs", p: [0.95], method: "approximate" } },
            p99: { $percentile: { input: "$durationMs", p: [0.99], method: "approximate" } }
        } }
      ]
  } }
]);

5. $out vs $merge — writing pipeline results

Both are terminal stages: they must appear last in the pipeline.

5.1 $out — wholesale collection replacement

db.sales.aggregate([
  { $group: { _id: "$region", revenue: { $sum: "$amount" } } },
  { $out: "regionRevenue" }
]);

// Or to a different database:
{ $out: { db: "analytics", coll: "regionRevenue" } }

Semantics:

5.2 $merge — incremental upsert

db.orders.aggregate([
  { $match: { date: { $gte: yesterday } } },
  { $group: {
      _id: { customer: "$customerId", date: { $dateTrunc: { date: "$date", unit: "day" } } },
      revenue: { $sum: "$total" },
      orders: { $sum: 1 }
  } },
  { $merge: {
      into: { db: "analytics", coll: "dailyCustomerRevenue" },
      on: "_id",
      whenMatched: "merge",
      whenNotMatched: "insert"
  } }
]);

Required key:

Optional keys:

5.3 Materialized view refresh patterns

Full rebuild on schedule (use $out):

// nightly
db.events.aggregate([
  { $match: { ts: { $gte: ISODate("2020-01-01") } } },
  { $group: { _id: { day: { $dateTrunc: { date: "$ts", unit: "day" } } },
              events: { $sum: 1 } } },
  { $out: "dailyEventCounts" }
]);

Incremental refresh (use $merge with a watermark):

// every 5 minutes, only the latest window
db.events.aggregate([
  { $match: { ts: { $gte: ISODate(lastRun) } } },
  { $group: { _id: { day: { $dateTrunc: { date: "$ts", unit: "day" } } },
              events: { $sum: 1 } } },
  { $merge: { into: "dailyEventCounts", on: "_id",
              whenMatched: [
                { $set: { events: { $add: ["$events", "$$new.events"] } } }
              ],
              whenNotMatched: "insert" } }
]);

The custom whenMatched pipeline lets the new run add to the existing total rather than replace it — essential for additive rollups where you process only the delta.

5.4 Idempotency rules

5.5 Pitfalls


6. $setWindowFields — window functions

Introduced in 5.0, $setWindowFields brings SQL-style window functions to MongoDB: running totals, moving averages, lead/lag, ranks, derivatives, integrals — all computed over a window of documents while every input document survives in the output (unlike $group).

6.1 Anatomy

{ $setWindowFields: {
    partitionBy: <expression>,           // optional, defaults to all-one-partition
    sortBy: { <field>: 1|-1, ... },      // required for most window operators
    output: {
      <newField>: {
        <accumulator>: <expression>,
        window: {
          documents: [ <lower>, <upper> ]   // OR
          range:     [ <lower>, <upper> ], unit: <"second"|"minute"|...>
        }
      },
      ...
    }
} }

You cannot mix documents and range in the same window.

6.2 Operator catalog

Accumulators usable inside $setWindowFields.output:

Operator Returns
$sum Running / windowed sum
$avg Running / windowed average
$min/$max Window min / max
$count Document count in window
$stdDevPop / $stdDevSamp Population / sample standard deviation
$covariancePop / $covarianceSamp Covariance between two fields
$expMovingAvg Exponentially weighted moving average
$derivative Rate of change between window endpoints
$integral Trapezoidal integral over the window
$rank Rank within partition, with gaps after ties
$denseRank Rank within partition, no gaps after ties
$documentNumber Position within partition (unique per doc)
$shift Value from another document by offset, with default
$linearFill Linear interpolation across missing values
$locf Last observation carried forward (within partition)
$first / $last First / last document’s expression value in the window
$top / $topN / $bottom / $bottomN / $firstN / $lastN N-of order operators
$percentile / $median Quantile estimates

6.3 Ranking semantics

For sortBy values [7, 9, 9, 10]:

6.4 Running totals and moving averages

// Daily cumulative revenue per customer.
db.orders.aggregate([
  { $setWindowFields: {
      partitionBy: "$customerId",
      sortBy: { orderDate: 1 },
      output: {
        cumulativeRevenue: {
          $sum: "$total",
          window: { documents: ["unbounded", "current"] }
        },
        sevenDayAvg: {
          $avg: "$total",
          window: { range: [-6, 0], unit: "day" }
        }
      }
  } }
]);

6.5 Lead / lag with $shift

// Compare each order to the previous order from the same customer.
db.orders.aggregate([
  { $setWindowFields: {
      partitionBy: "$customerId",
      sortBy: { orderDate: 1 },
      output: {
        prevTotal: { $shift: { output: "$total", by: -1, default: null } },
        nextTotal: { $shift: { output: "$total", by: 1,  default: null } }
      }
  } },
  { $addFields: { deltaVsPrev: { $subtract: ["$total", "$prevTotal"] } } }
]);

6.6 Derivative and integral for time-series

// Speed (derivative of distance) and total distance (integral of speed)
// across each device's reading stream.
db.telemetry.aggregate([
  { $setWindowFields: {
      partitionBy: "$deviceId",
      sortBy: { ts: 1 },
      output: {
        speedKmh: {
          $derivative: { input: "$distanceKm", unit: "hour" },
          window: { documents: [-1, 0] }
        },
        cumulativeExposure: {
          $integral: { input: "$radiation", unit: "minute" },
          window: { documents: ["unbounded", "current"] }
        }
      }
  } }
]);

$derivative requires unit for time-based sortBy fields and returns the rate per that unit. $integral returns the trapezoidal area under the curve over the window.

6.7 Memory limit

$setWindowFields is subject to the 100 MB / allowDiskUse rule per partition, not per pipeline overall. Very wide partitions with unbounded windows can spill. Reduce partition size by adding partitionBy keys, or bound the window with documents / range.


7. $densify — close gaps in numeric / time ranges

$densify (5.1+) inserts synthetic documents to make a sequence dense along a numeric or date field. The synthetic documents carry only the densified field (and any partition fields); other fields are absent and typically populated by a following $fill stage.

7.1 Syntax

{ $densify: {
    field: <numeric or date field>,
    partitionByFields: [ <field>, ... ],   // optional
    range: {
      step: <number>,
      unit: <"second"|"minute"|"hour"|"day"|"week"|"month"|"quarter"|"year">,
      bounds: <"full"|"partition"|[ <lower>, <upper> ]>
    }
} }

bounds values:

unit is required when field is a date and optional for numeric fields.

7.2 Hourly observations example

db.readings.aggregate([
  { $match: { deviceId: { $in: deviceList } } },
  { $densify: {
      field: "ts",
      partitionByFields: ["deviceId"],
      range: { step: 1, unit: "hour", bounds: "partition" }
  } }
]);

Result: every device gets one document per hour from its earliest to its latest reading. Missing hours appear as { deviceId, ts } with no other fields.

7.3 Numeric densification example

db.weeklySales.aggregate([
  { $densify: {
      field: "week",
      partitionByFields: ["region"],
      range: { step: 1, bounds: [1, 52] }
  } }
]);

Use cases: tax periods, fiscal weeks, leaderboard rank slots — anywhere a key is expected to be contiguous but real data has holes.

7.4 Rules


8. $fill — populate null and missing values

$fill (5.3+) sets a value for fields that are null or missing. It is typically paired with $densify to fill in the synthetic gap rows, but works on any pipeline.

8.1 Methods

Method Behavior
{ value: <expr> } Constant or computed value
{ method: "linear" } Linear interpolation between non-null values
{ method: "locf" } Last Observation Carried Forward

For linear and locf, you must supply sortBy so ordering is defined.

8.2 Syntax

{ $fill: {
    partitionByFields: [ <field>, ... ],   // optional
    partitionBy: <expression>,             // optional, exclusive with partitionByFields
    sortBy: { <field>: 1|-1, ... },        // required for linear/locf
    output: {
      <field>: { method: "linear" },
      <field>: { method: "locf" },
      <field>: { value: <expression> }
    }
} }

8.3 Time-series gap fill (densify + fill)

The canonical pattern from the MongoDB blog:

db.storageRoom.aggregate([
  // Bucket to the hour.
  { $group: {
      _id: { room: "$room", hour: { $dateTrunc: { date: "$ts", unit: "hour" } } },
      tempC:    { $avg: "$tempC" },
      motion:   { $max: "$motion" },
      inventory:{ $last: "$inventory" }
  } },
  { $set: { room: "$_id.room", ts: "$_id.hour" } },
  { $unset: "_id" },

  // Make sure every hour exists for every room.
  { $densify: {
      field: "ts",
      partitionByFields: ["room"],
      range: { step: 1, unit: "hour", bounds: "partition" }
  } },

  // Fill the holes.
  { $fill: {
      partitionByFields: ["room"],
      sortBy: { ts: 1 },
      output: {
        tempC:     { method: "linear" },
        motion:    { value: 0 },
        inventory: { method: "locf" }
      }
  } },

  { $sort: { room: 1, ts: 1 } }
]);

8.4 $linearFill expression (vs $fill’s linear method)

$linearFill is also available as a window function inside $setWindowFields. Use the standalone $fill stage when you want a self-contained gap-fill step; use $linearFill inside $setWindowFields when you are already partitioning / sorting for other window operators.

8.5 Rules


9. $unionWith — UNION ALL across collections

$unionWith (4.4+) appends documents from another collection (and optionally its own pipeline) to the running stream. It is MongoDB’s equivalent of SQL UNION ALL.

9.1 Syntax

{ $unionWith: { coll: <name>, pipeline: [ ... ] } }
// or shorthand
{ $unionWith: <collectionName> }

pipeline is optional and runs on the union’d collection’s documents before they are appended. The current pipeline’s documents pass through untouched and the union’d documents are appended at this point in the pipeline.

9.2 Multi-year sales example

db.sales2024.aggregate([
  { $unionWith: { coll: "sales2025" } },
  { $unionWith: { coll: "sales2026" } },
  { $group: {
      _id: "$item",
      qty: { $sum: "$qty" },
      revenue: { $sum: "$total" }
  } },
  { $sort: { revenue: -1 } }
]);

9.3 Removing duplicates (UNION not UNION ALL)

$unionWith includes duplicates. To deduplicate, follow it with $group:

db.suppliers.aggregate([
  { $project: { state: 1, _id: 0 } },
  { $unionWith: { coll: "warehouses",
                  pipeline: [ { $project: { state: 1, _id: 0 } } ] } },
  { $group: { _id: "$state" } }
]);

9.4 Rules

9.5 Use cases


10. Memory limits, allowDiskUse, and explain

10.1 The 100 MB-per-stage rule

Each blocking aggregation stage may use up to 100 MB of RAM for its in-memory state. Blocking stages include $sort, $group, $bucket, $bucketAuto, $setWindowFields, $facet (per branch), $graphLookup, and $lookup (when buffering).

When a stage exceeds the limit:

10.2 Enabling disk spill

db.collection.aggregate(pipeline, { allowDiskUse: true });

Equivalent settings:

10.3 explain("executionStats") for spill detection

db.collection.explain("executionStats").aggregate(pipeline);

Key signals in the output:

10.4 The 16 MB document limit

10.5 Stage ordering for memory and performance

The optimizer reorders stages where it can, but you should write pipelines that already minimize the working set:

  1. $match first — filter early, ideally on an indexed field.
  2. $project / $unset next — drop fields you do not need.
  3. $sort before $group only if the sort is needed for output, and only after the dataset is reduced.
  4. $lookup / $graphLookup after pruning — every joined document multiplies cost.
  5. Terminal $out / $merge last.

10.6 Sharded aggregation specifics

10.7 Quick spill triage

Symptom Action
usedDisk: true on $sort Add a $match upstream, add an index supporting the sort, or pre-filter
usedDisk: true on $group Reduce _id cardinality, project away fields before $group, run nightly with $merge
usedDisk: true on $setWindowFields Narrow partitions, bound the window, or split the pipeline
BSONObjectTooLarge on $facet $limit every branch; move heavy branches outside $facet
BSONObjectTooLarge on $group + $push Use $bucket or $merge to a collection
$lookup NestedLoopJoin Add index on foreignField, or rewrite as pipeline form
$graphLookup blows memory Set maxDepth, add restrictSearchWithMatch, index connectToField

11. Stage interaction cheat sheet

Goal Composition
Pagination with total $match -> $facet({ data: [$sort,$skip,$limit], meta: [$count] })
Daily revenue rollup, incremental $match(watermark) -> $group -> $merge(on=_id, whenMatched=pipeline-add)
Time-series gap-filled chart $group(bucket) -> $densify -> $fill(linear/locf) -> $sort
Moving average + rank $setWindowFields({ partitionBy, sortBy, output: { ma:$avg, rk:$denseRank } })
Hierarchy with depth $match(root) -> $graphLookup(maxDepth,depthField)
Faceted dashboard widget $match -> $facet({ summary, buckets, top })
Multi-source union for search $unionWith* -> $project(common) -> $group(dedupe)
Join with index pushdown $match(local) -> $lookup(localField/foreignField + index) -> $unwind
Correlated subquery join $lookup({ let, pipeline:[ $match($expr), $project, $limit ] })

12. Practical recipes

12.1 Top-N per group (without $group + $slice 16 MB risk)

// Top 3 highest-priced products per category.
db.products.aggregate([
  { $setWindowFields: {
      partitionBy: "$category",
      sortBy: { price: -1 },
      output: { rank: { $denseRank: {} } }
  } },
  { $match: { rank: { $lte: 3 } } }
]);

12.2 Bucketed latency report

db.requests.aggregate([
  { $match: { ts: { $gte: oneHourAgo } } },
  { $facet: {
      hist: [
        { $bucket: {
            groupBy: "$durationMs",
            boundaries: [0, 50, 100, 250, 500, 1000, 2000, 5000],
            default: "5000+",
            output: { count: { $sum: 1 } }
        } }
      ],
      pct:  [
        { $group: { _id: null,
            p50: { $percentile: { input: "$durationMs", p: [0.5],  method: "approximate" } },
            p95: { $percentile: { input: "$durationMs", p: [0.95], method: "approximate" } },
            p99: { $percentile: { input: "$durationMs", p: [0.99], method: "approximate" } }
        } }
      ]
  } }
]);

12.3 Materialized daily-revenue refresh job

function refreshDailyRevenue(lastRun) {
  return db.orders.aggregate([
    { $match: { updatedAt: { $gt: lastRun } } },
    { $group: {
        _id: { c: "$customerId", d: { $dateTrunc: { date: "$orderDate", unit: "day" } } },
        revenue: { $sum: "$total" },
        orders:  { $sum: 1 }
    } },
    { $merge: {
        into: "dailyCustomerRevenue",
        on: "_id",
        whenMatched: [
          { $set: {
              revenue: { $add: [ "$revenue", "$$new.revenue" ] },
              orders:  { $add: [ "$orders",  "$$new.orders"  ] }
          } }
        ],
        whenNotMatched: "insert"
    } }
  ]);
}

12.4 Hierarchical permission resolution

// All permissions for a user via nested group membership.
db.users.aggregate([
  { $match: { _id: userId } },
  { $graphLookup: {
      from: "groups",
      startWith: "$groupIds",
      connectFromField: "memberOfGroupIds",
      connectToField: "_id",
      as: "allGroups",
      maxDepth: 10
  } },
  { $project: {
      _id: 1,
      effectivePermissions: {
        $setUnion: {
          $reduce: {
            input: "$allGroups",
            initialValue: [],
            in: { $concatArrays: ["$$value", "$$this.permissions"] }
          }
        }
      }
  } }
]);

12.5 Gap-filled per-device hourly chart data

db.telemetry.aggregate([
  { $match: { ts: { $gte: dayAgo }, deviceId: { $in: deviceList } } },
  { $group: {
      _id: { d: "$deviceId", h: { $dateTrunc: { date: "$ts", unit: "hour" } } },
      tempC: { $avg: "$tempC" },
      battery: { $last: "$battery" }
  } },
  { $project: { _id: 0, deviceId: "$_id.d", ts: "$_id.h", tempC: 1, battery: 1 } },
  { $densify: {
      field: "ts",
      partitionByFields: ["deviceId"],
      range: { step: 1, unit: "hour", bounds: "partition" }
  } },
  { $fill: {
      partitionByFields: ["deviceId"],
      sortBy: { ts: 1 },
      output: { tempC: { method: "linear" }, battery: { method: "locf" } }
  } },
  { $sort: { deviceId: 1, ts: 1 } }
]);

13. Anti-patterns


14. Version reference

Capability Minimum MongoDB version
$lookup equality form 3.2
$graphLookup 3.4
$facet, $bucket, $bucketAuto 3.4
$lookup pipeline form with let 3.6
$merge 4.2
$unionWith 4.4
Sharded foreign collection in $lookup 5.1
$setWindowFields and window functions 5.0
$densify 5.1
$fill, $linearFill, $locf 5.3
$lookup containing $search / $searchMeta 6.0
allowDiskUseByDefault = true 6.0
Hash join optimizer strategy for $lookup 6.0

15. Sources

  1. MongoDB Docs — $lookup (aggregation stage)
  2. MongoDB Docs — $graphLookup (aggregation stage)
  3. MongoDB Docs — $facet (aggregation stage)
  4. MongoDB Docs — $bucket (aggregation stage)
  5. MongoDB Docs — $bucketAuto (aggregation stage)
  6. MongoDB Docs — $merge (aggregation stage)
  7. MongoDB Docs — $out (aggregation stage)
  8. MongoDB Docs — $setWindowFields (aggregation stage)
  9. MongoDB Docs — $densify (aggregation stage)
  10. MongoDB Docs — $fill (aggregation stage)
  11. MongoDB Docs — $linearFill (expression)
  12. MongoDB Docs — $unionWith (aggregation stage)
  13. MongoDB Docs — Aggregation Pipeline Limits
  14. MongoDB Blog — Introducing Gap Filling For Time Series Data in MongoDB 5.3
  15. MongoDB Developer Hub — Preparing time-series data with $densify and $fill
  16. Practical MongoDB Aggregations Book — Faceted Classification
  17. MongoDB Performance Tuning (Guy Harrison) — Getting started with MongoDB 5.0 window functions
  18. Percona — Window Functions in MongoDB 5.0