MongoDB's $map, $filter and $reduce aggregation operators never gave you the position of the element you were looking at. If you needed to know whether you were on index 0 or index 4,999, you had to build that yourself, usually by mapping over $range(0, $size(arr)) and pulling each value back out with $arrayElemAt. MongoDB 8.3 adds arrayIndexAs, a field that hands $map the index directly, no extra stage required.
The release notes describe this as a convenience change. I wanted to know what the workaround was actually costing people, so I ran both patterns against the same data and measured the difference.
The two patterns
Before 8.3, getting {index, value} pairs out of an array field looked like this:
db.readings.aggregate([
{ $project: { out: { $map: {
input: { $range: [0, { $size: "$arr" }] },
as: "i",
in: { idx: "$$i", val: { $arrayElemAt: ["$arr", "$$i"] } }
} } } }
])
8.3 lets you skip the $range/$arrayElemAt round trip entirely:
db.readings.aggregate([
{ $project: { out: { $map: {
input: "$arr",
as: "val",
arrayIndexAs: "idx",
in: { idx: "$$idx", val: "$$val" }
} } } }
])
Both produce the identical output. I checked that directly, document by document, before trusting any timing number.
What it costs today
I ran MongoDB 8.3.11 in Docker, seeded single documents holding one large array each (sizes from 10,000 to 320,000 floats), and timed both pipelines with explain({executionStats}), which reports the server's own executionTimeMillis rather than anything touched by network or driver overhead. Five repeats per size, minimum reported, because the first and second runs are usually warming up WiredTiger's buffer cache.
| array size | old pattern | arrayIndexAs |
old ÷ new |
|---|---|---|---|
| 10,000 | 3ms | 2ms | 1.5x |
| 40,000 | 14ms | 12ms | 1.17x |
| 80,000 | 29ms | 24ms | 1.21x |
| 160,000 | 58ms | 50ms | 1.16x |
| 320,000 | 134ms | 102ms | 1.31x |
The gap holds steady at roughly 15-30% once arrays get into the tens of thousands of elements. Below about 10,000 elements the numbers are inside run-to-run noise and I would not trust either figure.
I had expected something closer to quadratic. The old pattern does two passes conceptually — build the index range, then look an element up for each one — and if $arrayElemAt scanned the array from the front each time, a 320,000-element array would make the old pattern enormously slower, not 31% slower. It isn't. MongoDB's in-memory array representation supports direct positional access, so the workaround's cost is closer to a fixed per-element overhead from constructing the range array and re-looking-up a value that $map already had in hand. That is a real cost, just a smaller and more boring one than I assumed going in.
The gap that disappears under load
A single quiet connection is not what a production MongoDB instance looks like. I reran the comparison with pymongo, firing ten aggregations against the 320,000-element document, first one at a time and then all ten concurrently against a four-core container. I ran that four times to get past single-trial noise and averaged the wall-clock totals.
| concurrency | old pattern (wall time for 10 calls, avg of 4 runs) |
arrayIndexAs (same) |
old ÷ new |
|---|---|---|---|
| 1 (sequential) | 3,137ms | 2,788ms | 1.13x |
| 10 (parallel) | 1,950ms | 1,859ms | 1.05x |
Run sequentially, the new operator keeps roughly the same edge as the server-side numbers above. Run ten at once, the gap shrinks from 13% to about 5% — it doesn't vanish, but it drops by more than half. The server has four cores, and ten CPU-bound aggregations spend most of their time waiting for one to free up rather than running. Once the bottleneck is "wait for a core," shaving time off one aggregation's inner loop buys you less, because the next aggregation in the queue was waiting on a core either way, not on CPU-efficient code.
That is the finding I would actually act on: arrayIndexAs makes a lone aggregation faster, and it still helps under concurrent load, but by much less than the single-connection number suggests. On a CPU-saturated instance, the bigger lever is core count or concurrent query volume, not the choice of aggregation operator.
What it refuses
8.3 also ships $createObjectId, which generates a random ObjectId inside a pipeline. It takes exactly one argument: an empty object.
$createObjectId with {x: 1}
Invalid $project :: caused by :: $createObjectId only accepts the empty object
as argument. To convert a value to an ObjectId, use $toObjectId.
That error is specific enough to immediately tell you what to do instead, which is more than I can say for the next one. arrayIndexAs and the as variable are not allowed to share a name:
arrayIndexAs same name as 'as' ("x" for both)
Invalid $project :: caused by :: 'as' and 'arrayIndexAs' cannot have the same name
Both of those are fine, ordinary input validation. The one that would actually catch someone out is what happens on a cluster where the binary has been upgraded to 8.3 but featureCompatibilityVersion has not. I set FCV back to 8.2 on my test instance and tried both new features:
arrayIndexAs under FCV 8.2:
Invalid $project :: caused by :: Unrecognized parameter to $map: arrayIndexAs
$createObjectId under FCV 8.2:
Invalid $project :: caused by :: $createObjectId is not allowed in the current
feature compatibility version. See https://docs.mongodb.com/master/release-notes/8.0-compatibility/#feature-compatibility
for more information.
$createObjectId tells you exactly what's wrong and points at the compatibility docs. arrayIndexAs gives you a generic parser error that looks exactly like a typo, with no mention of FCV at all. If you've upgraded the binary and started writing 8.3-only pipelines before running setFeatureCompatibilityVersion, the $createObjectId error will send you to the right page. The arrayIndexAs error will send you to double-check your spelling instead, and you could lose real time there before realising the binary and the FCV disagree. Running setFeatureCompatibilityVersion: "8.3" and retrying both fixed it immediately, so the workaround is trivial once you know what you're looking for; the documentation gap is in the error message, not the fix.
One documented claim that held up
MongoDB's docs say $$IDX is available as the current index inside $map even when you don't declare arrayIndexAs at all. I checked that directly, projecting $$IDX with no arrayIndexAs field present:
db.readings.aggregate([
{ $project: { v: { $map: { input: "$arr", as: "val", in: "$$IDX" } } } }
])
// => [0, 1, 2, 3, ..., 99]
That held up exactly as documented. It's a nice detail if you're already in a codebase that references $$IDX and only needs the custom variable name for readability, not for the index itself to exist.
What I got wrong on the way
My first attempt at the concurrency test spawned ten separate mongosh processes from the shell, one per docker exec, and timed the whole thing with time. The single-connection numbers that came out of that harness showed the old pattern finishing in 536ms and the new one in 682ms — the opposite of every other measurement I'd taken. I nearly wrote that down as a genuine inversion.
It wasn't one. Each docker exec mongosh ... pays for shell startup, Node startup inside mongosh, and a fresh connection handshake, all of which dwarf a 100-300ms aggregation and vary by hundreds of milliseconds between runs. I was measuring process spin-up noise, not query cost. Switching to a single Python process holding a connection pool open via pymongo, and issuing the aggregations through a thread pool instead of fresh OS processes, is what produced the clean, consistent numbers in the tables above.
Run it yourself
This assumes Docker and Python 3 are available locally.
docker run -d --name mongotest -p 27117:27017 mongo:8.3
python3 -m venv venv && venv/bin/pip install pymongo
# bench.py
import time, statistics
from concurrent.futures import ThreadPoolExecutor
from pymongo import MongoClient
client = MongoClient("mongodb://localhost:27117/", maxPoolSize=50)
db = client.bench
db.readings.drop()
db.readings.insert_one({"size": 320000, "arr": [i * 0.5 for i in range(320000)]})
OLD = [{"$match": {"size": 320000}}, {"$project": {"out": {"$map": {
"input": {"$range": [0, {"$size": "$arr"}]}, "as": "i",
"in": {"idx": "$$i", "val": {"$arrayElemAt": ["$arr", "$$i"]}}}}}}]
NEW = [{"$match": {"size": 320000}}, {"$project": {"out": {"$map": {
"input": "$arr", "as": "val", "arrayIndexAs": "idx",
"in": {"idx": "$$idx", "val": "$$val"}}}}}]
def run_one(pipeline):
t0 = time.perf_counter()
list(db.readings.aggregate(pipeline))
return time.perf_counter() - t0
def bench(pipeline, concurrency, n_calls=10):
with ThreadPoolExecutor(max_workers=concurrency) as pool:
t0 = time.perf_counter()
latencies = [f.result() for f in [pool.submit(run_one, pipeline) for _ in range(n_calls)]]
return time.perf_counter() - t0, latencies
for label, pipeline in [("old", OLD), ("new", NEW)]:
for c in (1, 10):
wall, lat = bench(pipeline, c)
print(f"{label} concurrency={c} wall={wall*1000:.1f}ms mean={statistics.mean(lat)*1000:.1f}ms")
venv/bin/python3 bench.py
To see the FCV error for yourself:
docker exec mongotest mongosh --quiet --eval \
'db.getSiblingDB("admin").adminCommand({setFeatureCompatibilityVersion: "8.2", confirm: true})'
docker exec mongotest mongosh --quiet --eval \
'db.getSiblingDB("bench").readings.aggregate([{$limit:1},{$project:{v:{$map:{input:"$arr",as:"val",arrayIndexAs:"idx",in:"$$idx"}}}}])'
If you're on 8.3 already and reaching for the old $range/$arrayElemAt pattern out of habit, switch it over. The saving is real but modest on a quiet server, and it stops mattering at all once your deployment is CPU-bound under concurrent aggregations — in that case, look at core count and concurrent query volume before you look at which array operator you used.
Top comments (0)