Sitemap
Vasanthan K

Vasanthan K

Senior Full Stack Engineer | Scalable Enterprise Systems | Node.js • GraphQL • React/Vue • Mongodb | Azure Cloud | 10+ Year | Gen AI enthusiast

MongoDB 8.3 Aggregation Additions You Probably Missed ($$IDX, arrayIndexAs, Hashing, $createObjectId)

Why these small additions matter

22 min read1 day ago

--

MongoDB 8.3 (8.3.0 shipped on May 4, 2026) quietly added four aggregation features that remove some of the most annoying workarounds in everyday MongoDB code: the $$IDX variable, the arrayIndexAs field, two hashing expressions ($hash and $hexHash), and $createObjectId.

Most release-note readers skipped straight past them. The headline items were performance gains, the Cost-Based Ranker, and $scoreFusion going GA. But if you write aggregation pipelines every week, these four small things will change how your code looks more than any headline feature.

Press enter or click to view image in full size

The four additions covered in this article.

Here is what each one solves, in one line:

Press enter or click to view image in full size

This article is written so that a beginner can follow every line. Each section starts with the idea in plain words, then shows a working example, then walks through the code piece by piece. Experienced readers can jump straight to the code blocks and the gotchas section near the end.

All the diagrams in this article were drawn specifically for it.

A two-minute refresher for beginners

If you already write aggregation pipelines comfortably, skip this section. If not, these five ideas are all you need for the rest of the article.

1. A pipeline is an assembly line

An aggregation pipeline is a list of stages. Documents flow into the first stage, get changed, and the result flows into the next stage. Each stage is one object in an array, such as $match (keep only some documents), $project or $addFields (reshape documents), $group (combine documents), and $out / $merge (write results to a collection).

db.orders.aggregate([
{ $match: { status: "paid" } }, // stage 1: filter
{ $addFields: { itemCount: { $size: "$items" } } }, // stage 2: reshape
{ $sort: { itemCount: -1 } } // stage 3: order
])

2. Stages vs expressions

A stage works on whole documents. An expression works on values inside one document. In the example above, $addFields is a stage, and { $size: "$items" } is an expression that calculates one value. All four features in this article are expressions or expression variables, so they live inside stages like $project, $addFields, and $set.

3. One dollar sign vs two dollar signs

This trips up almost every beginner, so read it slowly:

  • "$items" (one $) means a field of the current document.
  • "$$this" (two $$) means a variable. Variables are temporary names that only exist while an expression is running.

MongoDB ships some built-in system variables, like $$NOW (the current time) and $$ROOT (the whole current document). MongoDB 8.3 adds a new one: $$IDX.

4. The three array “loop” expressions

MongoDB has no for loop, but it has three expressions that walk through an array one element at a time. Think of them as JavaScript's array methods:

Press enter or click to view image in full size

Inside each loop, MongoDB gives you a variable for the current element. In $map and $filter it is $$this by default (or any name you set with as). In $reduce there are two: $$this for the current element and $$value for the running total.

5. The missing piece before 8.3

JavaScript’s map callback gets a second argument for free: (item, index) => …. MongoDB's loops never had that index. You knew what the current element was, but not where it sat in the array. That one gap is what $$IDX and arrayIndexAs close.

Setup: check your version and load the sample data

Everything here needs a MongoDB 8.3 or newer server. MongoDB 8.3 is a minor release: it runs on Atlas, and since 8.2, minor releases are also available for on-premises Community and Enterprise deployments for specific use cases. Open mongosh and confirm:

db.version()
// '8.3.x' -> good to go

db.adminCommand({ getParameter: 1, featureCompatibilityVersion: 1 })
// { featureCompatibilityVersion: { version: '8.3' }, ok: 1 }

Why the second command matters: a server binary can be on 8.3 while its feature compatibility version (FCV) is still on the old version after an upgrade. New features are often held back until the FCV is raised, so if a new operator throws “unrecognized expression”, check the FCV first.

We will use two small collections throughout the article. Paste both into mongosh:

// A tiny online shop: each order has a list of line items
db.orders.insertMany([
{
_id: 1,
customer: "asha@example.com",
items: [
{ sku: "PEN", qty: 3, price: 2 },
{ sku: "BOOK", qty: 1, price: 15 },
{ sku: "BAG", qty: 1, price: 40 }
]
},
{
_id: 2,
customer: "ravi@example.com",
items: [
{ sku: "MUG", qty: 2, price: 8 },
{ sku: "PEN", qty: 10, price: 2 }
]
}
])


// A temperature sensor that records one reading per hour
db.sensors.insertOne({
_id: "greenhouse-1",
readings: [20, 22, 21, 25, 24]
})

What you just created: orders has two documents, and each holds an items array of objects. sensors has one document with a plain array of numbers. Those two shapes (array of objects, array of numbers) cover almost every real-world use of the new index features.

Feature 1: $$IDX and arrayIndexAs in $map

The idea in one sentence: while $map, $filter, or $reduce walks through an array, $$IDX tells you the position of the element it is currently looking at, starting from 0.

arrayIndexAs is the same thing with a custom name. Write arrayIndexAs: "pos" and the index is available as $$pos instead of $$IDX. The official rule is simple: if you set arrayIndexAs, use your name; if you leave it out, use $$IDX.

Press enter or click to view image in full size

How $map walks an array: on every step it hands you the element ($$this) and its position ($$IDX).

Example 1: number the line items of an order

We want every line item to carry a lineNo field: 1 for the first item, 2 for the second, and so on. Humans count from 1, arrays count from 0, so lineNo is $$IDX + 1.

The old way (before 8.3)

db.orders.aggregate([
{
$project: {
items: {
$map: {
input: { $range: [0, { $size: "$items" }] }, // [0, 1, 2]
as: "i",
in: {
$mergeObjects: [
{ $arrayElemAt: ["$items", "$$i"] }, // look the item up again
{ lineNo: { $add: ["$$i", 1] } }
]
}
}
}
}
}
])

What this code is really doing: since $map could not tell us the index, we built a fake array of numbers [0, 1, 2] with $range and $size, looped over those numbers, and then used $arrayElemAt to go back and fetch the real item at each number. It works, but the code no longer reads like what it means, and every element is looked up a second time.

The new way (8.3)

db.orders.aggregate([
{
$project: {
items: {
$map: {
input: "$items",
in: {
$mergeObjects: [
"$$this",
{ lineNo: { $add: ["$$IDX", 1] } }
]
}
}
}
}
}
])

Line-by-line walkthrough:

  1. $project is the stage. It decides which fields come out of the pipeline. We output a new version of items.
  2. $map loops over input: "$items", the real array. No fake number array this time.
  3. in is the expression MongoDB runs once per element. Whatever it returns becomes that element in the new array.
  4. $$this is the current line item, for example { sku: "PEN", qty: 3, price: 2 }.
  5. $$IDX is its position: 0 for PEN, 1 for BOOK, 2 for BAG.
  6. $add: ["$$IDX", 1] turns 0, 1, 2 into 1, 2, 3.
  7. $mergeObjects glues the original item and the new { lineNo: … } object into one object.

Result for order 1:

{
_id: 1,
items: [
{ sku: "PEN", qty: 3, price: 2, lineNo: 1 },
{ sku: "BOOK", qty: 1, price: 15, lineNo: 2 },
{ sku: "BAG", qty: 1, price: 40, lineNo: 3 }
]
}
Press enter or click to view image in full size

Old workaround vs the 8.3 way: the same result with two fewer operators and no second lookup per element.

The same thing with arrayIndexAs

When the pipeline grows, $$this and $$IDX say nothing about what they hold. Naming both makes the code read like a sentence:

$map: {
input: "$items",
as: "item", // the element is now $$item
arrayIndexAs: "pos", // the index is now $$pos
in: {
$mergeObjects: ["$$item", { lineNo: { $add: ["$$pos", 1] } }]
}
}

The output is identical. Pick $$IDX for short one-off expressions and arrayIndexAs when names help the next reader. Variable names must start with a lowercase letter, so pos works and Pos does not.

Example 2: compare each reading with the previous one

This is where the index becomes genuinely powerful. Knowing your position means you can look at your neighbours. Let us compute how much the temperature changed since the previous hour.

db.sensors.aggregate([
{
$project: {
change: {
$map: {
input: "$readings",
as: "temp",
arrayIndexAs: "i",
in: {
$cond: {
if: { $eq: ["$$i", 0] }, // the first reading has no previous one
then: null,
else: {
$subtract: [
"$$temp",
{ $arrayElemAt: ["$readings", { $subtract: ["$$i", 1] }] }
]
}
}
}
}
}
}
}
])
// { _id: 'greenhouse-1', change: [ null, 2, -1, 4, -1 ] }

How it works: for the element at position i, the previous element is at position i - 1. $arrayElemAt: ["$readings", i - 1] fetches it, and $subtract gives the difference. For readings [20, 22, 21, 25, 24] that is: nothing before 20, then 22 − 20 = 2, 21 − 22 = −1, 25 − 21 = 4, 24 − 25 = −1.

Why the $cond is not optional: at position 0, i - 1 is −1, and $arrayElemAt treats a negative index as "count from the end". Without the guard, the first value would silently become 20 − 24 = −4, a wrong number with no error. Whenever you use $$IDX - 1 or $$IDX + 1, guard the edges of the array.

Feature 1, continued: $$IDX in $filter

$filter keeps the elements whose cond evaluates to true. In 8.3, cond can use the element's position, so you can filter by where an element is, by what it is, or by both together.

The full $filter syntax in 8.3 looks like this:

{
$filter: {
input: <array expression>,
as: <string>, // optional, defaults to "this"
arrayIndexAs: <string>, // optional, new in 8.3
cond: <expression>, // must return true or false
limit: <number expression> // optional, stop after N matches
}
}

Example 1: everything except the first item

Say the first line in every cart is the “main” product and everything after it is an add-on. We want only the add-ons:

db.orders.aggregate([
{
$project: {
addOns: {
$filter: {
input: "$items",
cond: { $gt: ["$$IDX", 0] } // keep positions 1, 2, 3, ...
}
}
}
}
])
// { _id: 1, addOns: [ { sku: 'BOOK', ... }, { sku: 'BAG', ... } ] }
// { _id: 2, addOns: [ { sku: 'PEN', ... } ] }

Reading the code: for each item, MongoDB asks “is your position greater than 0?”. PEN at position 0 says no and is dropped. Every other item says yes and is kept.

An honest note for beginners: when the condition is only about position, the older $slice is simpler: { $slice: ["$items", 1, { $size: "$items" }] } gives the same result. $$IDX in $filter earns its place when position and content are tested together, as in the next example.

Example 2: keep only the readings that went up

We want the readings that were higher than the reading just before them. That needs both the value ($$t) and the position ($$i), so we name them:

db.sensors.aggregate([
{
$project: {
risingReadings: {
$filter: {
input: "$readings",
as: "t",
arrayIndexAs: "i",
cond: {
$and: [
{ $gt: ["$$i", 0] }, // has a previous reading
{ $gt: ["$$t", { $arrayElemAt: ["$readings", { $subtract: ["$$i", 1] }] }] }
]
}
}
}
}
}
])
// { _id: 'greenhouse-1', risingReadings: [ 22, 25 ] }

Tracing it by hand with [20, 22, 21, 25, 24]:

Press enter or click to view image in full size

Tracing a pipeline with a small table like this is the single best habit a beginner can build. If the table and the output disagree, the bug is in your mental model, not in MongoDB.

Example 3: every other element

The official documentation shows a neat trick: keep only elements at even positions (0, 2, 4…) using the remainder operator $mod:

$filter: {
input: "$items",
cond: { $eq: [{ $mod: ["$$IDX", 2] }, 0] } // 0, 2, 4, ... have remainder 0
}
// order 1 -> [ PEN, BAG ] (positions 0 and 2)

This is handy for sampling long arrays, or for data stored as alternating pairs, such as [label, value, label, value].

Feature 1, continued: $reduce gets an index and two new names

$reduce got the biggest upgrade of the three loops. Besides arrayIndexAs / $$IDX, MongoDB 8.3 added two more optional fields to it:

Press enter or click to view image in full size

Before 8.3, a nested $reduce was painful: inner and outer loops both used $$this and $$value, so you had to wrap things in $let just to keep them apart. Now each loop can have its own names.

Press enter or click to view image in full size

$reduce step by step: each pass receives the running total, the current element, and its index, and returns the new running total.

Example 1: build a numbered receipt line

db.orders.aggregate([
{
$project: {
receipt: {
$reduce: {
input: "$items",
initialValue: "Items:",
valueAs: "text", // running string is $$text
as: "item", // current line item is $$item
arrayIndexAs: "n", // current position is $$n
in: {
$concat: [
"$$text", " ",
{ $toString: { $add: ["$$n", 1] } }, ") ",
"$$item.sku", " x", { $toString: "$$item.qty" }
]
}
}
}
}
}
])
// { _id: 1, receipt: 'Items: 1) PEN x3 2) BOOK x1 3) BAG x1' }
// { _id: 2, receipt: 'Items: 1) MUG x2 2) PEN x10' }

How $reduce thinks, step by step (order 1):

  1. Start: $$text = "Items:" (the initialValue).
  2. Pass 1: $$n = 0, $$item = PEN. New $$text = "Items: 1) PEN x3".
  3. Pass 2: $$n = 1, $$item = BOOK. New $$text = "Items: 1) PEN x3 2) BOOK x1".
  4. Pass 3: $$n = 2, $$item = BAG. New $$text = the final string.
  5. The array is finished, so the last $$text becomes the field's value.

Two details beginners often miss: $concat only accepts strings, which is why both numbers go through $toString; and $$item.sku uses dot notation to read a field out of the element object.

Example 2: a weighted average where recent readings count more

A plain average treats every hour the same. Often the latest readings matter more. Here, reading number 1 gets weight 1, reading number 2 gets weight 2, and so on. The index is the weight, which was awkward before 8.3 and is one line now:

db.sensors.aggregate([
{
$project: {
totals: {
$reduce: {
input: "$readings",
initialValue: { weightedSum: 0, totalWeight: 0 },
valueAs: "acc",
as: "r",
arrayIndexAs: "i",
in: {
weightedSum: { $add: ["$$acc.weightedSum", { $multiply: ["$$r", { $add: ["$$i", 1] }] }] },
totalWeight: { $add: ["$$acc.totalWeight", { $add: ["$$i", 1] }] }
}
}
}
}
},
{
$project: {
weightedAvg: { $round: [{ $divide: ["$totals.weightedSum", "$totals.totalWeight"] }, 2] }
}
}
])
// { _id: 'greenhouse-1', weightedAvg: 23.13 }

The math, so you can check it: weighted sum = 20×1 + 22×2 + 21×3 + 25×4 + 24×5 = 347. Total weight = 1 + 2 + 3 + 4 + 5 = 15. 347 ÷ 15 = 23.13. The plain average is 22.4, so the weighted one correctly leans toward the warmer recent hours.

Get Vasanthan K’s stories in your inbox

Join Medium for free to get updates from this writer.

New concept here: the accumulator does not have to be a number or a string. It can be an object carrying several running values at once (weightedSum and totalWeight). valueAs: "acc" makes $$acc.weightedSum read far better than $$value.weightedSum.

Feature 2: hashing inside the pipeline with $hash and $hexHash

The idea in one sentence: a hash function turns any input into a short, fixed-size fingerprint; the same input always gives the same fingerprint, and a tiny change to the input gives a completely different one.

Think of it like a fingerprint for data. You cannot rebuild a person from a fingerprint, but you can check whether two fingerprints match. Hashes are used to detect changes, find duplicates, split data into buckets, and hide raw values (like emails) in analytics.

Until 8.3, MongoDB had no general way to do this in a pipeline. You had to read the documents into your application, hash them in Node.js or Python, and write them back. MongoDB 8.3 adds two expressions that do it on the server.

Press enter or click to view image in full size

Same input, same algorithm, two output formats: $hash returns raw bytes, $hexHash returns readable text.

Syntax

Both expressions take the same two fields:

{ $hash:    { input: <expression>, algorithm: <"md5" | "sha256" | "xxh64"> } }
{ $hexHash: { input: <expression>, algorithm: <"md5" | "sha256" | "xxh64"> } }
  • input must resolve to a UTF-8 string or binary data (BinData).
  • algorithm is required and must be one of the three names above.
  • If input is null or the field is missing, the result is null. Any other type (a number, a date, an object) is an error, so convert it to a string first.

The only difference is the output format:

Press enter or click to view image in full size

Choosing an algorithm

Press enter or click to view image in full size

Beginner rule of thumb: if a human or an attacker could gain anything by forging a collision, use sha256. If you just need a fast, well-spread fingerprint inside your own system, use xxh64. Use md5 only to match something that already uses MD5.

Example 1: pseudonymize customer emails for an analytics export

Your analytics team needs to count orders per customer but must not see email addresses. Replace each email with its SHA-256 fingerprint:

db.orders.aggregate([
{
$project: {
_id: 0,
customerKey: { $hexHash: { input: "$customer", algorithm: "sha256" } },
itemCount: { $size: "$items" }
}
}
])
// {
// customerKey: 'EA4E36A829983D27E865B649CD10FCB61F2F1BCD857B47AB709E7ADBE941AB68',
// itemCount: 3
// }
// {
// customerKey: '10571C2886AA8A2B620F6869F7E312B8905BAFEB23B71FB6BCA529F5669CED65',
// itemCount: 2
// }

What happened: "asha@example.com" went in, a 64-character hex fingerprint came out. The same email will produce the same key in every future export, so the analytics team can still group and count by customer without ever seeing who the customer is.

Important caveat: a hash is not encryption. Emails are guessable, so someone could hash a list of known emails and compare. For real privacy, mix in a secret value (a “pepper”) before hashing, for example { $concat: ["$customer", "<secret>"] }, and keep that secret out of logs and source control. Never use these expressions to store passwords; use a slow password hash such as bcrypt or Argon2 in your application.

Example 2: binary output with $hash

db.aggregate([
{ $documents: [{ val: "hello" }] },
{ $project: { _id: 0, hash: { $hash: { input: "$val", algorithm: "xxh64" } } } }
])
// [ { hash: Binary.createFromBase64('JseCfYifbaM=', 0) } ]

$documents is a handy stage that creates documents on the fly, so you can test expressions without any collection. The result is 8 bytes of binary data with subtype 0 (generic binary). The shell displays those bytes in Base64. The same hash through $hexHash reads 26C7827D889F6DA3.

Example 3: split customers into stable buckets

A/B tests, gradual feature rollouts, and parallel batch jobs all need the same thing: put each customer into one of N groups, and keep them in the same group forever. A hash makes that deterministic. This example also uses another 8.3 addition, the base option of $convert, to read hex digits as a number:

db.orders.aggregate([
{
$project: {
customer: 1,
bucket: {
$mod: [
{
$convert: {
input: { $substrCP: [{ $hexHash: { input: "$customer", algorithm: "xxh64" } }, 0, 4] },
to: "int",
base: 16 // read "F67D" as hexadecimal (new in 8.3)
}
},
4 // 4 buckets: 0, 1, 2, 3
]
}
}
}
])
// { _id: 1, customer: 'asha@example.com', bucket: 1 }
// { _id: 2, customer: 'ravi@example.com', bucket: 3 }

Reading it from the inside out:

  1. $hexHash with xxh64 turns asha@example.com into F67DD25E3A6E3832.
  2. $substrCP keeps the first 4 characters: F67D.
  3. $convert with base: 16 reads F67D as a hexadecimal number: 63101.
  4. $mod: [63101, 4] is the remainder after dividing by 4: 1.

Run it next month and Asha still lands in bucket 1. No lookup table, no stored assignment.

Example 4: detect whether an order’s items changed

Another 8.3 change lets $toString turn whole objects and arrays into strings. Hash that string and you get a compact fingerprint of the entire items array:

db.orders.aggregate([
{
$project: {
itemsFingerprint: {
$hexHash: { input: { $toString: "$items" }, algorithm: "xxh64" }
}
}
}
])
// { _id: 1, itemsFingerprint: '<16 hex characters>' }

Store the fingerprint, and on the next sync compare the stored value with a freshly computed one. Equal fingerprints mean nothing changed, so the expensive work can be skipped. One caution: the string form depends on field order, so { sku, qty } and { qty, sku } hash differently even though they hold the same data.

Feature 3: $createObjectId generates IDs inside the database

The idea in one sentence: $createObjectId gives you a brand-new ObjectId value in the middle of a pipeline or a pipeline-style update, exactly like calling new ObjectId() in your app, but without leaving the database.

A quick reminder of what an ObjectId is: the 12-byte value MongoDB puts in _id by default, shown as 24 hex characters, such as ObjectId('65f1c2a9e4b0a1b2c3d4e5f6'). Its first 4 bytes are a timestamp, so ObjectIds also sort roughly by creation time.

Syntax

{ $createObjectId: {} }

That is all of it. The argument must be an empty object {}. Anything else fails with a FailedToParse error. Every evaluation returns a new value.

Do not confuse it with its older cousin:

Press enter or click to view image in full size
Press enter or click to view image in full size

Before and after $unwind: every line item inherits the order's _id, and $createObjectId gives each one an identity of its own.

Example 1: split orders into a line-items collection

This is the most common real-world use. $unwind turns one order with three items into three documents, one per item. But all three keep the same _id (the order's), and _id must be unique in a collection. Writing them with $out fails with a duplicate key error.

db.orders.aggregate([
{ $unwind: "$items" }, // 2 orders -> 5 documents
{
$project: {
_id: { $createObjectId: {} }, // a fresh, unique _id per line
orderId: "$_id", // keep a link back to the order
sku: "$items.sku",
qty: "$items.qty",
price: "$items.price"
}
},
{ $out: "lineItems" }
])


db.lineItems.find()
// { _id: ObjectId('...'), orderId: 1, sku: 'PEN', qty: 3, price: 2 }
// { _id: ObjectId('...'), orderId: 1, sku: 'BOOK', qty: 1, price: 15 }
// { _id: ObjectId('...'), orderId: 1, sku: 'BAG', qty: 1, price: 40 }
// { _id: ObjectId('...'), orderId: 2, sku: 'MUG', qty: 2, price: 8 }
// { _id: ObjectId('...'), orderId: 2, sku: 'PEN', qty: 10, price: 2 }
// (your ObjectId values will differ: they are generated at run time)

What each stage does:

  1. $unwind: "$items" makes one copy of the order per array element and replaces the items array with that single element.
  2. In $project, "$_id" on the right-hand side still reads the incoming document's _id (the order number), so we can save it as orderId while giving the new document a fresh _id.
  3. $out writes the five documents to a new lineItems collection.

Before 8.3 you would either drop _id and let $out fail, build a compound _id like { order: 1, line: 0 }, or export everything to your app just to generate IDs.

Example 2: give every embedded item a permanent ID (pipeline-style update)

updateMany accepts an aggregation pipeline instead of a normal update document. That means $createObjectId works in updates too, so you can backfill IDs into existing data in one command:

db.orders.updateMany(
{ "items.itemId": { $exists: false } }, // only orders not yet migrated
[
{
$set: {
items: {
$map: {
input: "$items",
in: { $mergeObjects: ["$$this", { itemId: { $createObjectId: {} } }] }
}
}
}
}
]
)

Why the square brackets matter: the second argument is an array [ ... ], which tells MongoDB "this is a pipeline, not a plain update". Only the pipeline form can run expressions such as $map and $createObjectId.

Why the filter matters: $createObjectId is not idempotent. Running this update twice without the filter would replace every itemId with a new one and break anything that stored the old IDs. The $exists: false filter makes a second run a harmless no-op.

Example 3: give a view a usable _id (from the official docs)

Some stages, notably $graphLookup, use _id internally to track documents they have already visited. If a view hides _id, those stages cannot tell documents apart. The MongoDB documentation shows the fix: a second view that adds a fresh _id.

// A view that hides the original _id
db.createView("moviesView", "movies", [
{ $project: { _id: 0, title: 1, cast: 1 } }
])

// A second view on top that adds a unique _id
db.createView("moviesViewWithId", "moviesView", [
{ $project: { _id: { $createObjectId: {} }, title: 1, cast: 1 } }
])
// $graphLookup can now treat each movie as a distinct node
db.movies.aggregate([
{
$graphLookup: {
from: "moviesViewWithId",
startWith: "$cast",
connectFromField: "cast",
connectToField: "cast",
as: "relatedMovies"
}
}
])

A view is a saved pipeline that runs every time you query it. So the IDs it produces are new on every query. They are fine for stages that only need uniqueness within one run. Never store them or use them as references.

Putting it all together: one pipeline, all four features

Here is a realistic job that would have needed application code before 8.3: turn raw orders into an analytics-ready, privacy-safe line-items collection. Each output row needs its own ID, its line number, whether it was an add-on, a line total, and a pseudonymized customer key.

Press enter or click to view image in full size

The combined pipeline: index the items, unwind them, then stamp each line with a new ID and a hashed customer key before writing it out.

db.orders.aggregate([
// Stage 1: number every item while it is still inside its array
{
$set: {
items: {
$map: {
input: "$items",
as: "item",
arrayIndexAs: "pos",
in: {
$mergeObjects: [
"$$item",
{
lineNo: { $add: ["$$pos", 1] },
isAddOn: { $gt: ["$$pos", 0] }
}
]
}
}
}
}
},

// Stage 2: one document per line item
{ $unwind: "$items" },
// Stage 3: shape the output row
{
$project: {
_id: { $createObjectId: {} },
orderId: "$_id",
customerKey: { $hexHash: { input: "$customer", algorithm: "sha256" } },
lineNo: "$items.lineNo",
isAddOn: "$items.isAddOn",
sku: "$items.sku",
lineTotal: { $multiply: ["$items.qty", "$items.price"] }
}
},
// Stage 4: write the result
{ $out: "analyticsLines" }
])

Output (db.analyticsLines.find()):

{ _id: ObjectId('...'), orderId: 1, customerKey: 'EA4E36A8...AB68', lineNo: 1, isAddOn: false, sku: 'PEN',  lineTotal: 6  }
{ _id: ObjectId('...'), orderId: 1, customerKey: 'EA4E36A8...AB68', lineNo: 2, isAddOn: true, sku: 'BOOK', lineTotal: 15 }
{ _id: ObjectId('...'), orderId: 1, customerKey: 'EA4E36A8...AB68', lineNo: 3, isAddOn: true, sku: 'BAG', lineTotal: 40 }
{ _id: ObjectId('...'), orderId: 2, customerKey: '10571C28...ED65', lineNo: 1, isAddOn: false, sku: 'MUG', lineTotal: 16 }
{ _id: ObjectId('...'), orderId: 2, customerKey: '10571C28...ED65', lineNo: 2, isAddOn: true, sku: 'PEN', lineTotal: 20 }
// (hashes shortened here for space; the real field holds all 64 characters)

Why the stages are in this order:

  • The index must be captured before $unwind. Once the array is unwound, each document holds a single item and there is no array left to have positions in. That is why Stage 1 stamps lineNo and isAddOn while the items are still together. ($unwind also has an older includeArrayIndex option that records the position; $$IDX is more flexible because you can compute with it on the spot, as isAddOn does.)
  • $createObjectId must come after $unwind. Before it, there are 2 documents; after it, there are 5. IDs are per output row, so they are generated where the rows exist.
  • Hashing happens before $out, so raw emails never land in the analytics collection at all.

The whole transformation now runs on the server in a single command. Nothing travels to your app and back, which matters a lot once “two orders” becomes two million.

Gotchas worth knowing before you ship

  1. Version and FCV. All of this needs MongoDB 8.3+. After an upgrade, also check the feature compatibility version. Remember that 8.3 is a minor release, so confirm your hosting option supports it before you build on it.
  2. Downgrades. MongoDB’s 8.3 notes say that downgrading requires removing anything that uses backward-incompatible features. Views, validators, or stored pipelines that use these operators will stop working on an older server, so list them before planning a rollback.
  3. $$IDX is the position in the input array. In $filter, the index of a kept element is its original position, not its position in the filtered result. Kept elements at positions 1 and 3 do not become 0 and 1.
  4. Guard the edges. $$IDX - 1 at position 0 is −1, and $arrayElemAt reads −1 as the last element. You get a wrong number with no error. Always wrap neighbour lookups in a $cond.
  5. Name your indexes when loops are nested. When a $map sits inside another $map, a bare $$IDX makes the reader work out which loop it belongs to. Give each loop its own arrayIndexAs (outerPos, innerPos) and the question disappears.
  6. $createObjectId is non-deterministic. Running the same pipeline twice gives different IDs. A $merge that matches on _id will therefore insert duplicates on every rerun instead of updating. Match on a stable business key instead (for example on: ["orderId", "lineNo"], backed by a unique index), and never store IDs that came from a view.
  7. Hash inputs are strict. Only strings and BinData are accepted. Numbers, dates, and objects throw an error, so wrap them in $toString first. null and missing fields quietly return null, which can hide data-quality problems, so count your nulls.
  8. MD5 is weak and sometimes unavailable. It is not cryptographically secure, and it is disabled when the server runs in FIPS mode. Prefer sha256 or xxh64 in new code.
  9. Field order changes hashes. When you hash $toString of an object, { a: 1, b: 2 } and { b: 2, a: 1 } produce different fingerprints. Build the object in a fixed field order before hashing it.
  10. Typed tooling may lag behind. These operators run on the server, so any driver can send them. Some typed query builders and ODMs may not know them yet, and you may need a type cast until your library catches up.

Cheat sheet and wrap-up

Press enter or click to view image in full size

Save this one: the whole article on a single card.

Press enter or click to view image in full size

None of these four features will make a keynote. What they do is remove the small, annoying round-trips and workarounds that made aggregation code harder to read than it needed to be: the $range + $arrayElemAt dance, hashing in app code, and generating IDs outside the database.

If you are starting out with aggregation, these additions make MongoDB’s array loops behave much more like the map, filter, and reduce you already know from JavaScript. If you are experienced, they are a good excuse to revisit old pipelines and delete some clever-but-ugly code.

Try the sample data above in mongosh, change a value, and predict the output before you run it. That habit will teach you more than any article.

Senior Full Stack Engineer | Scalable Enterprise Systems | Node.js • GraphQL • React/Vue • Mongodb | Azure Cloud | 10+ Year | Gen AI enthusiast

See more from me