Filter concepts by levelShowing all levels.

MongoDB · Section 20

Window Functions and Advanced Analytics

Level
advanced
Read
20 min
Concepts
3

How $setWindowFields computes a value across a window of related, ordered documents while keeping every document in the output — unlike $group — covering partitioning, sorting, rank-style and accumulator-style window operators for running totals and moving averages, and where the line sits between "aggregation handles this" and "this belongs in a dedicated analytics system".

MongoDB overview

What is true here

  1. $setWindowFields keeps one output document per input document, annotated with a window-computed value — $group collapses documents instead.
  2. partitionBy groups documents the window is computed within; sortBy defines the order needed for anything running/ranked.
  3. Rank operators ($rank, $denseRank) use sortBy directly; accumulator operators ($sum, $avg, …) need an explicit window to bound them.
  4. A window like { documents: ["unbounded", "current"] } turns an accumulator into a running total; a small fixed range turns it into a moving average.
  5. Known-shape reporting over live operational data fits aggregation; ad hoc, very large, or heavily cross-joined analytics fits a dedicated warehouse better.

What you will be able to do

  • Choose $setWindowFields over $group when per-row detail must be preserved
  • Use partitionBy and sortBy correctly for a ranking or running-total computation
  • Pick the right window operator — rank-style versus accumulator-style — for a given analytics need
  • Recognize when an analytical workload has outgrown in-place aggregation

$setWindowFields fundamentals

What makes a window function different from $group, and the operators available inside one.

$setWindowFields fundamentals

coreadvanced

$setWindowFields computes a value across a "window" of related documents — like the running total so far, or this row's rank within its group — while still outputting one document per input document, unlike $group which collapses documents together.

Think of it as

A $group answer is "one row per group". A $setWindowFields answer is "every row, plus something computed by looking at its neighbors" — the same distinction SQL draws between GROUP BY and a window function like SUM() OVER (...).

text
{ $setWindowFields: { partitionBy, sortBy, output: { <newField>: { <windowOperator>: <expr>, window? } } } }

What we're doing: Add a running total of sales per region, ordered by date, without collapsing the individual sale documents.

running-total.jsjavascript
db.sales.aggregate([
  { $setWindowFields: {
      partitionBy: "$region",
      sortBy: { date: 1 },
      output: {
        runningTotal: { $sum: "$amount", window: { documents: ["unbounded", "current"] } }
      }
  } }
])
2
partitionBy resets the running total independently for each region — East's running total does not include West's sales.
3
sortBy establishes the order "running" means — without it, there is no well-defined "so far".
6
The window says: sum from the start of this partition up to and including the current document — the standard running-total window.

Why this works: Every original sale document is still present in the output, now carrying its own runningTotal value — which is what makes this different from a $group that would collapse all of a region's sales into one summary document.

Reaching for $group when the goal is a per-row running value, not a per-group summary

Wrong

text
// $group: { _id: "$region", total: { $sum: "$amount" } }  — collapses every sale into one document per region, losing the individual rows

Better

javascript
$setWindowFields: { partitionBy: "$region", sortBy: { date: 1 }, output: { runningTotal: { $sum: "$amount", window: { documents: ["unbounded", "current"] } } } }

What you see: The individual sale documents disappear from the output, when the actual requirement was "each sale, plus its running total so far".

Why: $group is built to answer "one summary per group"; $setWindowFields is built to answer "every document, annotated with something computed across its neighbors" — picking the wrong one either loses the per-document detail or fails to compute the running value at all.

$group vs. $setWindowFields

$group

  • +collapses N documents into 1 per group
  • +original document fields are gone unless re-added
  • +answers "what is the total per group?"

$setWindowFields

  • keeps all N documents, one output per input
  • every original field is still there
  • answers "what is this row's running total / rank?"
  • $group
    • collapses N documents into 1 per group
    • original document fields are gone unless re-added
    • answers "what is the total per group?"
  • $setWindowFields
    • keeps all N documents, one output per input
    • every original field is still there
    • answers "what is this row's running total / rank?"

Remember: $setWindowFields keeps one output document per input, annotated with a value computed over a window of related, ordered documents — reach for it when $group would lose the per-row detail you need.

See also: window operators for analytics · core pipeline stages

Window operators: rank, running total, moving average

standardadvanced

$setWindowFields supports two kinds of operators: rank-style operators ($rank, $denseRank, $documentNumber) that need only sortBy, and accumulator-style operators ($sum, $avg, $min, $max) reused from $group but scoped to a window instead of a whole group.

Think of it as

Most of these operators are ones you already know from $group ($sum, $avg) — $setWindowFields just gives them a window (how many neighboring documents to include) instead of collapsing the whole partition, which is what turns "total per group" into "running total" or "moving average".

text
output: { <field>: { $rank: {} } } · output: { <field>: { $sum: <expr>, window: { documents: [<start>, <end>] } } }

What we're doing: Rank sales reps by revenue within each region, and compute a 3-sale moving average.

javascript
db.sales.aggregate([
  { $setWindowFields: {
      partitionBy: "$region",
      sortBy: { amount: -1 },
      output: {
        regionRank: { $rank: {} },
        movingAvg3: { $avg: "$amount", window: { documents: [-2, 0] } }
      }
  } }
])
6
$rank needs no window — it is defined entirely by sortBy order within each partition.
7
window: { documents: [-2, 0] } means "the current document and the 2 before it in sort order", which is what makes $avg here a 3-document moving average instead of a partition-wide one.

Why this works: Rank-style operators only need to know the order; accumulator-style operators need an explicit window to say how much of the ordered sequence to include — that is the practical difference to remember when reaching for either kind.

Common window operators

Common window operators
OperatorNeeds a window?Computes
$rankno — uses sortBy directlyposition, with gaps after ties
$denseRankno — uses sortBy directlyposition, no gaps after ties
$sum / $avgyes, to bound itrunning total / moving average
$shiftno — takes an offset insteadvalue from a nearby row (e.g. previous)

Together

javascript
{ $setWindowFields: {
  partitionBy: "$region",
  sortBy: { amount: -1 },
  output: {
    rank: { $rank: {} },
    movingAvg3: { $avg: "$amount", window: { documents: [-2, 0] } }
  }
} }

Remember: Rank operators ($rank, $denseRank) need only sortBy; accumulator operators ($sum, $avg, …) need an explicit window to turn a group-wide total into a running or moving one.

See also: setwindowfields basics · core pipeline stages

Advertisement

Knowing the ceiling

When aggregation-based analytics is the right tool, and when a dedicated system serves the workload better.

Aggregation vs. a dedicated analytics system

standardadvanced

The aggregation framework, with $setWindowFields and $group, comfortably covers dashboards, reports, and per-tenant summaries over operational data. Ad hoc exploratory queries across billions of rows, or joins across many large datasets, usually belong in a dedicated warehouse instead.

Think of it as

Aggregation is analytics colocated with the operational data it is already querying — no separate pipeline, no data ever leaving MongoDB. A warehouse is analytics on a copy of the data, purpose-built and reshaped for exactly this kind of heavy, exploratory querying. Choose based on how far the workload has drifted from "reporting on live operational data".

text
// Signals to reconsider: exploratory ad hoc queries, heavy cross-dataset joins, or aggregations routinely hitting the memory limit

What we're doing: Contrast a report that fits aggregation against a workload that has outgrown it.

text
// Fits aggregation: "orders per day this month, by region" — known shape, moderate volume, same data as the app
db.orders.aggregate([{ $match: {...} }, { $group: {...} }])

// Has outgrown it: "run this analyst's ad hoc exploratory query across 3 years of every collection,
// joined against marketing and support data, competing with live production traffic"
// -> belongs in a data warehouse fed by change streams, not run against the primary
2
A known, repeatable report shape over the application's own data is exactly the workload aggregation is built for.
5
Ad hoc, cross-dataset, resource-heavy analytical work is the workload a warehouse exists to isolate from production traffic.

Why this works: Running heavy analytical workloads directly against the same database serving live application traffic risks resource contention with production — a warehouse exists precisely to isolate that risk while still letting the data stay fresh via change streams.

Remember: Aggregation is the right tool for known-shape reporting over live operational data — reach for a dedicated warehouse once the workload is ad hoc, very large, heavy on cross-dataset joins, or starts competing with production for resources.

See also: setwindowfields basics · memory and intermediate results

Advertisement