MongoDB 8.3 Aggregation Additions You Probably Missed ($$IDX, arrayIndexAs, Hashing, $createObjectId)
Why these small additions matter
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.
The four additions covered in this article.
Here is what each one solves, in one line:
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:
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.
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:
$projectis the stage. It decides which fields come out of the pipeline. We output a new version ofitems.$maploops overinput: "$items", the real array. No fake number array this time.inis the expression MongoDB runs once per element. Whatever it returns becomes that element in the new array.$$thisis the current line item, for example{ sku: "PEN", qty: 3, price: 2 }.$$IDXis its position: 0 for PEN, 1 for BOOK, 2 for BAG.$add: ["$$IDX", 1]turns 0, 1, 2 into 1, 2, 3.$mergeObjectsglues 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 }
]
}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]:
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:
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.
$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):
- Start:
$$text="Items:"(theinitialValue). - Pass 1:
$$n= 0,$$item= PEN. New$$text="Items: 1) PEN x3". - Pass 2:
$$n= 1,$$item= BOOK. New$$text="Items: 1) PEN x3 2) BOOK x1". - Pass 3:
$$n= 2,$$item= BAG. New$$text= the final string. - The array is finished, so the last
$$textbecomes 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.
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"> } }inputmust resolve to a UTF-8 string or binary data (BinData).algorithmis required and must be one of the three names above.- If
inputisnullor the field is missing, the result isnull. 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:
Choosing an algorithm
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:
$hexHashwithxxh64turnsasha@example.comintoF67DD25E3A6E3832.$substrCPkeeps the first 4 characters:F67D.$convertwithbase: 16readsF67Das a hexadecimal number: 63101.$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:
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:
$unwind: "$items"makes one copy of the order per array element and replaces theitemsarray with that single element.- In
$project,"$_id"on the right-hand side still reads the incoming document's_id(the order number), so we can save it asorderIdwhile giving the new document a fresh_id. $outwrites the five documents to a newlineItemscollection.
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.
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 stampslineNoandisAddOnwhile the items are still together. ($unwindalso has an olderincludeArrayIndexoption that records the position;$$IDXis more flexible because you can compute with it on the spot, asisAddOndoes.) $createObjectIdmust 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
- 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.
- 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.
$$IDXis 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.- Guard the edges.
$$IDX - 1at position 0 is −1, and$arrayElemAtreads −1 as the last element. You get a wrong number with no error. Always wrap neighbour lookups in a$cond. - Name your indexes when loops are nested. When a
$mapsits inside another$map, a bare$$IDXmakes the reader work out which loop it belongs to. Give each loop its ownarrayIndexAs(outerPos,innerPos) and the question disappears. $createObjectIdis non-deterministic. Running the same pipeline twice gives different IDs. A$mergethat matches on_idwill therefore insert duplicates on every rerun instead of updating. Match on a stable business key instead (for exampleon: ["orderId", "lineNo"], backed by a unique index), and never store IDs that came from a view.- Hash inputs are strict. Only strings and
BinDataare accepted. Numbers, dates, and objects throw an error, so wrap them in$toStringfirst.nulland missing fields quietly returnnull, which can hide data-quality problems, so count your nulls. - MD5 is weak and sometimes unavailable. It is not cryptographically secure, and it is disabled when the server runs in FIPS mode. Prefer
sha256orxxh64in new code. - Field order changes hashes. When you hash
$toStringof 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. - 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
Save this one: the whole article on a single card.
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.






