02 · Reshaping Documents with Expressions¶
Lesson 01 used stages. This lesson is about expressions — the small functional language you write
inside stages to compute values: totals from arrays, labels from numbers, local dates from UTC, clean
decimals from messy strings. The same expressions work in $project, $set, $group accumulators,
$match with $expr, pipeline updates (Level 1 · 06) and validators (Level 2 · 04), so learning them once
pays off everywhere.
Examples use the shop database from lesson 01.
Syntax in one paragraph¶
An expression is either a field path ("$city", "$items.qty"), a variable ("$$i", "$$NOW",
"$$ROOT"), a literal, or an operator document { $operator: [args] }. Operators nest freely:
{ $multiply: ["$items.qty", "$items.price"] }. Inside array operators you name a variable with as and
refer to it with two dollar signs. To use a literal string that starts with $, wrap it:
{ $literal: "$5 off" }.
$set, $unset and $project¶
$set(alias$addFields) adds or replaces fields and keeps everything else.$unsetremoves fields:{ $unset: ["items", "city"] }.$projectbuilds the output explicitly: include (field: 1), exclude (field: 0), or compute (field: <expression>).
Use $set when you're adding to documents, $project when you're defining a final output shape.
Working on arrays without $unwind¶
Lesson 01 unwound items to sum them. Often you don't need to: array expressions compute per document, which keeps the number of documents (and the meaning of every count) unchanged.
$map transforms every element:
db.orders.aggregate([
{ $match: { _id: { $in: [1, 2] } } },
{ $set: { lineTotals: { $map: { input: "$items", as: "i", in: { $multiply: ["$$i.qty", "$$i.price"] } } } } },
{ $set: { total: { $sum: "$lineTotals" },
itemCount: { $reduce: { input: "$items", initialValue: 0, in: { $add: ["$$value", "$$this.qty"] } } } } },
{ $project: { _id: 1, lineTotals: 1, total: 1, itemCount: 1 } }
])
{"_id":1,"lineTotals":[{"$numberDecimal":"254"},{"$numberDecimal":"1183"}],"total":{"$numberDecimal":"1437"},"itemCount":2}
{"_id":2,"lineTotals":[{"$numberDecimal":"2549"}],"total":{"$numberDecimal":"2549"},"itemCount":1}
$sum applied to an array (outside $group) adds its elements. $reduce folds an array into one value,
with $$value as the accumulator and $$this as the current element — here the total quantity.
$filter keeps matching elements. Orders (among the first eight) containing electronics, with just those
SKUs:
db.orders.aggregate([
{ $match: { _id: { $in: [1, 2, 3, 4, 5, 6, 7, 8] } } },
{ $project: { elec: { $filter: { input: "$items", as: "i", cond: { $eq: ["$$i.category", "electronics"] } } } } },
{ $match: { "elec.0": { $exists: true } } },
{ $project: { skus: "$elec.sku" } }
])
This is how you get all matching elements of an array — Level 1 · 05's positional projection only returned
the first. Note the path "$elec.sku" on an array of documents yields an array of the sku values.
Other array operators you'll use: $size, $arrayElemAt (negative indexes count from the end), $first
and $last (as expressions), $slice, $concatArrays, $in, $indexOfArray, $sortArray, $zip, and
the set operators $setUnion, $setIntersection, $setDifference.
db.orders.aggregate([{ $match: { _id: 1 } }, { $project: {
first: { $arrayElemAt: ["$items.sku", 0] }, last: { $last: "$items.sku" }, n: { $size: "$items" },
label: { $let: { vars: { n: { $size: "$items" } },
in: { $concat: [{ $toString: "$$n" }, " item(s) for ", "$city"] } } } } }])
$let names an intermediate value so you don't repeat a sub-expression.
Conditions¶
$cond is if-then-else; $switch is a chain of cases; $ifNull supplies a default for null or missing.
Banding orders by total:
db.orders.aggregate([
{ $set: { total: { $sum: { $map: { input: "$items", as: "i", in: { $multiply: ["$$i.qty", "$$i.price"] } } } } } },
{ $set: { band: { $switch: {
branches: [ { case: { $lt: ["$total", 1000] }, then: "small" },
{ case: { $lt: ["$total", 10000] }, then: "medium" } ],
default: "large" } } } },
{ $sortByCount: "$band" }
])
Branches are evaluated in order and the first true one wins, so ranges can be written as simple upper bounds.
Always give $switch a default; without one, a document matching no case is an error.
Dates and time zones¶
BSON dates are UTC. Convert at the edges — for display and for grouping by local day:
db.orders.aggregate([{ $match: { _id: { $in: [1, 3] } } }, { $project: {
cancelled: { $cond: [{ $eq: ["$status", "cancelled"] }, true, false] },
coupon: { $ifNull: ["$coupon", "none"] },
dow: { $dayOfWeek: "$placedAt" },
local: { $dateToString: { date: "$placedAt", format: "%Y-%m-%d %H:%M", timezone: "Asia/Kolkata" } },
utc: { $dateToString: { date: "$placedAt", format: "%Y-%m-%d %H:%M" } } } }])
{"_id":1,"cancelled":false,"coupon":"none","dow":7,"local":"2026-02-21 17:18","utc":"2026-02-21 11:48"}
{"_id":3,"cancelled":false,"coupon":"none","dow":5,"local":"2026-01-15 05:56","utc":"2026-01-15 00:26"}
$dayOfWeek returns 1 for Sunday through 7 for Saturday (21 February 2026 is a Saturday). Order 3 was placed
at 00:26 UTC — already 05:56 in India. A "daily sales" report grouped by UTC day would put orders placed
between midnight and 05:30 IST on the previous day. Pass timezone to $dateTrunc, $dateToString,
$dayOfWeek and friends whenever the business day isn't UTC. $dateAdd, $dateDiff and $dateSubtract
handle calendar arithmetic (month lengths, leap years) correctly; adding milliseconds by hand does not.
Reshaping whole documents¶
$replaceWith (alias of $replaceRoot: {newRoot: ...}) makes an expression the whole document. Combined with
$mergeObjects, it's the cleanest way to flatten a line item with fields from its order:
db.orders.aggregate([{ $match: { _id: 1 } }, { $unwind: "$items" },
{ $replaceWith: { $mergeObjects: [{ orderId: "$_id", city: "$city" }, "$items"] } }])
{"orderId":1,"city":"Hyderabad","sku":"P169","category":"toys","qty":1,"price":{"$numberDecimal":"254"}}
{"orderId":1,"city":"Hyderabad","sku":"P055","category":"kitchen","qty":1,"price":{"$numberDecimal":"1183"}}
That flat shape is what you'd export to CSV (Level 1 · 09) or load into a BI tool.
Converting types safely¶
Imported data is often dirty. $convert converts with fallbacks; the $toX shortcuts don't have them:
db.raw.insertMany([{ v: "12.50" }, { v: "abc" }, { v: null }, { v: 7 }]);
db.raw.aggregate([{ $project: { _id: 0, v: 1,
asDec: { $convert: { input: "$v", to: "decimal", onError: "BAD", onNull: "MISSING" } } } }])
{"v":"12.50","asDec":{"$numberDecimal":"12.50"}}
{"v":"abc","asDec":"BAD"}
{"v":null,"asDec":"MISSING"}
{"v":7,"asDec":{"$numberDecimal":"7"}}
241 Executor error during aggregate command on namespace: shop.raw :: caused by :: Failed to parse number 'abc' in $convert ...
One bad value fails the whole pipeline. In cleanup pipelines, use $convert with onError, then $match the
failures into a separate report.
How It Actually Works¶
Expressions are parsed once, when the pipeline is built, into a tree of expression objects; for each document,
the stage evaluates the tree against that document's fields and the current variable scope. $$ROOT is the
whole input document, $$CURRENT the document being processed (usually the same), and $$NOW/$$CLUSTER_TIME
are fixed for the whole operation, so every document sees the same "now". Array operators like $map and
$filter create a new scope per element with the variable you named, which is why $$i is visible only inside
the in/cond expression.
Expressions run inside the server on BSON values, so they preserve types: $multiply of an int and a Decimal128
yields Decimal128 (as in the line totals above), integer overflow promotes to long or double, and comparisons
follow the BSON type order from Level 1 · 03. The optimiser also performs constant folding — an expression with
no field references is computed once — and can push $set/$project work past $match stages when that's safe.
Per-document expressions are cheap compared to moving documents between stages. Computing an order total with
$map/$sum keeps one document per order; $unwind + $group creates one document per line item and then
rebuilds groups in a hash table. Reach for $unwind when you need to group by something inside the array
(revenue per SKU), not when you only need to aggregate within each document.
Common mistakes¶
$unwind+$groupto compute per-document totals that$map/$sumwould compute in place.- One
$instead of two inside$map/$filter("$i.qty"is a field path,"$$i.qty"the variable). - Grouping by UTC day when the business operates in another time zone.
$switchwithout a default.$toDecimal/$toInton dirty data without$convert'sonError.- Expecting
$projectto keep fields you didn't list — that's$set's job.
Exercise¶
Using the shop database:
- Add
totalanditemCountto every order with one$setusing$map,$sumand$reduce, then find the 10 orders with the highestitemCount. - For each order, compute
electronicsShare: the fraction of the order total spent on electronics, rounded to two decimals ($round). Return orders where it is between 0.4 and 0.6. - Produce "orders per local (IST) hour of day" for delivered orders, and compare the shape with the same report in UTC.
- Flatten orders to one row per line item with
orderId,customerId, local date string,sku,qtyandlineTotal, and export the first 1,000 rows to CSV with$out+mongoexport(lesson 05 covers$out). - Write a pipeline that classifies each customer as
new(first order in the last 90 days of the data),activeorlapsed(no order in the last 120 days of the data), using$switchand$dateDiff.