Filter concepts by levelShowing all levels.

MongoDB · Section 17

Aggregation Expressions

Level
intermediate
Read
25 min
Concepts
6

The expression operators used inside $project, $set, $group and other stages: the arithmetic/comparison/boolean/string/date/array/object families, the three ways to branch ($cond, $ifNull, $switch), the array expressions that replace $unwind for simple transforms ($map, $filter, $reduce), date expressions for grouping and bucketing, type conversion with explicit failure handling, and why a well-indexed pipeline can still be CPU-heavy.

MongoDB overview

What is true here

  1. Expression operators compute a value inside a stage — they are the pipeline's formula language, not its filtering language.
  2. $ifNull, $cond and $switch cover one-fallback, two-branch, and multi-branch decisions respectively.
  3. $map, $filter and $reduce transform an array field without changing the document count, unlike $unwind.
  4. Date expressions round a timestamp down to a bucket (day, hour, week) before grouping by it — grouping on the raw timestamp fragments every result.
  5. $convert and its shorthands need onError/onNull set explicitly whenever input quality is not guaranteed.

What you will be able to do

  • Choose the right expression family for a computed field
  • Pick between $ifNull, $cond and $switch based on the number of branches
  • Transform or reduce an array field with $map/$filter/$reduce instead of $unwind + $group
  • Group time-series data by day/hour/week using date expressions, with the correct timezone
  • Convert types safely with explicit onError/onNull fallbacks

Computing and branching

The core formula vocabulary — arithmetic, comparison, string and array expressions — plus the three ways to branch inside one.

Aggregation expression categories

standardintermediate

Aggregation expressions are the functions you use inside $project, $set, $group and other stages to compute a value from a document's fields, instead of just copying them through. They are grouped by what they work on: numbers, comparisons, strings, dates, arrays, and so on.

Think of it as

Think of expressions as the formula language of the pipeline — the same role a spreadsheet formula plays over a row. A stage says which documents to touch; an expression says how to compute one field's new value from the others.

text
{ $operator: <expression> } or { $operator: [<expression>, ...] }

What we're doing: Compute a line-item total and a display label from raw fields, in one $set stage.

javascript
db.orderItems.aggregate([
  { $set: {
      lineTotal: { $multiply: ["$unitPrice", "$quantity"] },
      label: { $concat: ["$sku", " x", { $toString: "$quantity" }] }
  } }
])
2
$multiply takes an array of expressions and multiplies them — here, two field references.
3
$concat only accepts strings, so $toString converts the numeric quantity before joining it in.

Why this works: Expressions compose the same way normal function calls do — an expression can be another expression's argument, which is how one $set stage builds several derived fields from the same raw data.

Expression families and where each is used

Expression families and where each is used
FamilyExamplesTypical use
Arithmetic$add, $multiply, $dividecompute a total, a rate, a percentage
Comparison$eq, $gt, $ltebranch inside $cond or filter inside $filter
Boolean$and, $or, $notcombine multiple conditions into one
String$concat, $toUpper, $substrCPbuild a display label or normalize casing
Object$mergeObjects, $objectToArrayreshape a subdocument

Together

javascript
{ $set: {
  fullName: { $concat: ["$firstName", " ", "$lastName"] },
  total: { $multiply: ["$price", "$quantity"] },
  isLarge: { $gt: ["$quantity", 100] }
} }

Remember: Expression operators ($add, $concat, $gt, …) are the pipeline's formula language — they compute a value inside a stage, and nest inside each other like function calls.

See also: cond ifnull switch · map filter reduce

$cond, $ifNull, $switch

standardintermediate

$cond is an if/then/else for one condition. $ifNull returns the first non-null value from a list, usually a default when a field is missing. $switch picks one of several branches by testing conditions in order, like an if/else-if chain.

Think of it as

Reach for the smallest tool: $ifNull for "use this or fall back to that", $cond for exactly two outcomes, $switch once there are three or more branches — $switch nested $cond expressions read like pyramid-of-doom code and are harder to review.

text
$ifNull: [<expr>, <fallback>] · $cond: { if, then, else } · $switch: { branches: [{ case, then }], default }

What we're doing: Classify orders into a shipping tier using $switch, with $ifNull filling a missing region before the check.

javascript
db.orders.aggregate([
  { $set: { region: { $ifNull: ["$region", "unknown"] } } },
  { $set: { shipping: { $switch: {
      branches: [
        { case: { $eq: ["$region", "unknown"] }, then: "hold" },
        { case: { $eq: ["$region", "domestic"] }, then: "standard" }
      ],
      default: "international"
  } } } }
])
2
A missing region becomes the literal string "unknown" before anything else inspects it, so the switch never has to handle a null case separately.
3
$switch checks branches top to bottom; the first matching case wins and the rest are skipped.

Why this works: Running $ifNull first collapses the missing-field case into a normal value the rest of the pipeline can compare against, which keeps every later $switch branch a simple equality check.

Choosing between the three

Choosing between the three
OperatorShapeUse when
$ifNull$ifNull: [expr, fallback]one field, one fallback value
$cond$cond: { if, then, else }exactly two outcomes
$switch$switch: { branches, default }three or more mutually exclusive outcomes

Together

javascript
{ $set: {
  displayName: { $ifNull: ["$nickname", "$fullName"] },
  tier: { $switch: {
      branches: [
        { case: { $gte: ["$spend", 1000] }, then: "gold" },
        { case: { $gte: ["$spend", 100] },  then: "silver" }
      ],
      default: "bronze"
  } }
} }

Remember: $ifNull is a fallback for one value, $cond is a two-way branch, $switch is an ordered if/else-if chain with a default — pick the smallest one that fits.

See also: expression categories overview · logical operators

$map, $filter, $reduce

coreintermediate

$map transforms every element of an array into something else. $filter keeps only the elements matching a condition. $reduce folds an array down into a single value, carrying an accumulator from element to element. All three run entirely inside the pipeline, without $unwind.

Think of it as

These are the same three operations most languages give you for arrays — map, filter, fold — just spelled as pipeline expressions instead of function calls. Reaching for one of them instead of $unwind avoids exploding a document into N pipeline documents just to touch its array field.

text
$map: { input, as, in } · $filter: { input, as?, cond, limit? } · $reduce: { input, initialValue, in }

What we're doing: From an order's line-items array, keep only shipped items and sum their totals — without $unwind.

array-expressions.jsjavascript
db.orders.aggregate([
  { $set: {
      shippedItems: { $filter: {
          input: "$items",
          as: "item",
          cond: { $eq: ["$$item.status", "shipped"] }
      } }
  } },
  { $set: {
      shippedTotal: { $reduce: {
          input: "$shippedItems",
          initialValue: 0,
          in: { $add: ["$$value", "$$this.price"] }
      } }
  } }
])
3
$filter walks the items array and keeps only elements where status equals "shipped" — the document count stays exactly one.
8
$reduce walks the filtered array once, carrying a running sum in $$value and the current element in $$this, ending with a single number.

Why this works: The whole computation happens on one document's array field, in place — no stage ever multiplies the document count, which is what $unwind followed by $group would otherwise require to reach the same single total.

Reaching for $unwind + $group when the goal is a single per-document scalar

Wrong

text
// $unwind: "$items" → $match: { "items.status": "shipped" } → $group: { _id: "$_id", total: { $sum: "$items.price" } } — three stages just to add up an array field

Better

javascript
$reduce: { input: { $filter: { input: "$items", cond: { $eq: ["$$this.status", "shipped"] } } }, initialValue: 0, in: { $add: ["$$value", "$$this.price"] } }

What you see: A pipeline that only needed one output document per input document instead produces one document per array element, requiring an extra $group to undo the explosion.

Why: $unwind multiplies documents by array length before any reduction happens; $filter and $reduce operate on the array value directly and never change the document count, so no re-grouping step is needed afterward.

$unwind + $group vs. $map/$filter/$reduce for the same result

Unwind, then re-group

  • +$unwind explodes the array into N documents
  • +$group re-collapses them back to one
  • +more pipeline documents in flight, more stages

Array expression

  • $map/$filter/$reduce work on the array in place
  • the document count never changes
  • one $set stage, no explode/re-collapse round trip
  • Unwind, then re-group
    • $unwind explodes the array into N documents
    • $group re-collapses them back to one
    • more pipeline documents in flight, more stages
  • Array expression
    • $map/$filter/$reduce work on the array in place
    • the document count never changes
    • one $set stage, no explode/re-collapse round trip

Remember: $map transforms, $filter keeps, $reduce folds — all three run inside the document, on the array field, without $unwind exploding it into multiple pipeline documents first.

See also: filter and map over full unwind · array operators

Advertisement

Dates, types, and cost

Bucketing timestamps for grouping, converting between BSON types safely, and where expression cost hides from the query planner.

Date expressions for grouping

standardintermediate

Date expressions pull a piece out of a BSON date — year, month, day, hour, week, day-of-week — so you can $group events by that piece instead of by the exact timestamp, which would put almost every document in its own group.

Think of it as

A raw timestamp is too precise to group by directly — two events a millisecond apart are "different" to an exact match. Date expressions round a timestamp down to the bucket you actually care about (day, month, hour), which is what turns a pile of timestamps into "events per day".

text
$dateTrunc: { date, unit, binSize?, timezone? } · $dateToString: { date, format, timezone? }

What we're doing: Group orders by calendar day, in the customer's local timezone, and count them.

javascript
db.orders.aggregate([
  { $group: {
      _id: { $dateToString: { date: "$placedAt", format: "%Y-%m-%d", timezone: "America/New_York" } },
      count: { $sum: 1 }
  } },
  { $sort: { _id: 1 } }
])
3
Formatting to just the year-month-day string collapses every order placed on the same calendar day into one group, regardless of the time of day it was placed.

Why this works: Passing a timezone matters here specifically because a day boundary in UTC and a day boundary in America/New_York are several hours apart — without it, orders near midnight would land in the wrong day's bucket for that customer.

Remember: Pull the component you want to group by ($year, $dateToString, $dateTrunc) before grouping — grouping on the raw timestamp puts almost every document in its own bucket, and always pass a timezone if UTC is not what the business means by "day".

See also: expression categories overview · time series bucketing

Type conversion expressions

standardintermediate

$convert changes a value from one BSON type to another — string to int, string to date, and so on — and lets you specify what to do if the conversion fails or the input is null, instead of the pipeline just throwing an error.

Think of it as

Data imported from CSV, a webhook, or an older schema often has the right value in the wrong type — a number stored as a string. $convert (and its shorthand siblings $toInt, $toDate, $toString) fix that at read or write time without a separate migration script.

text
$convert: { input, to, onError?, onNull? }

What we're doing: Coerce a price field that arrives as a string on some documents into a decimal, defaulting bad or missing values to 0 instead of failing the whole pipeline.

javascript
db.importedRows.aggregate([
  { $set: {
      price: { $convert: { input: "$price", to: "decimal", onError: 0, onNull: 0 } }
  } }
])
3
onError catches a string like "N/A" that cannot become a decimal; onNull catches a missing or null price field — both fall back to 0 instead of aborting the aggregation.

Why this works: A single malformed row in a large import should not stop the whole pipeline from producing results for every other row — onError/onNull turn a hard failure into a value you can filter or flag afterward.

Shorthand conversions and what they do on failure

Shorthand conversions and what they do on failure
ShorthandEquivalent toOn unconvertible input
$toInt$convert: { to: "int" }throws, unless wrapped in onError
$toDate$convert: { to: "date" }throws on an unparseable string
$toString$convert: { to: "string" }rarely fails — most types stringify

Together

javascript
{ $set: { total: { $convert: { input: "$total", to: "decimal", onError: 0, onNull: 0 } } } }

Remember: $convert (or its $to* shorthands) changes a value's BSON type — always set onError/onNull explicitly when the input quality is not guaranteed, or one bad document aborts the whole aggregation.

See also: bson types · expression categories overview

Expression cost and CPU pressure

referenceintermediate

Index usage bounds how many documents a pipeline examines, but not how much work each stage does once it has them — a $project or $group full of nested expressions runs real CPU work per document, and that cost is invisible to an index.

Remember: An index limits document count, not per-document compute — deeply nested expressions, regex, and string operations across millions of documents can be CPU-bound even with a perfect index.

See also: memory and intermediate results · scan to return ratios

Advertisement