DEV Community

Alex Georgiev
Alex Georgiev

Posted on AI-assisted

MongoDB 8.3's arrayIndexAs cuts a map-with-index workaround's overhead by up to 31%

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"] } }
  } } } }
])
Enter fullscreen mode Exit fullscreen mode

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" }
  } } } }
])
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

$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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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")
Enter fullscreen mode Exit fullscreen mode
venv/bin/python3 bench.py
Enter fullscreen mode Exit fullscreen mode

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"}}}}])'
Enter fullscreen mode Exit fullscreen mode

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)