Ch. 12 · MongoDB

MongoDB Multikey Indexes for Array Fields

How MongoDB indexes every array element, why $elemMatch matters for arrays of subdocuments, and the parallel-array limit on compound indexes.

~5 min readbeginnerupdated Oct 7, 2026

MongoDB lets you store arrays in documents and query into them as if each element were a value of the field. That works because of multikey indexes: when you index a field that holds an array, MongoDB stores one index key for each element. You create them with the normal createIndex call; the index becomes multikey the first time a document with an array in that field is inserted.

Examples are mongosh syntax for MongoDB 7 and 8. They weren’t run on a live server while writing, so outputs are illustrative.

One document, many keys

db.articles.insertOne({ _id: 1, title: "Index basics", tags: ["mongodb", "indexes", "performance"] })
db.articles.createIndex({ tags: 1 })
JavaScript

The tags index now holds three entries for this document: "indexes", "mongodb" and "performance", each pointing to _id: 1. A query for one tag goes straight to it:

db.articles.find({ tags: "mongodb" })
JavaScript

Note the syntax: you query tags: "mongodb", not tags: ["mongodb"]. The first means “the array contains mongodb”. The second means “the array is exactly ["mongodb"]”, which is a different question and won’t match this document.

Other common array queries:

db.articles.find({ tags: { $all: ["mongodb", "indexes"] } })   // contains both
db.articles.find({ tags: { $in: ["redis", "mongodb"] } })      // contains either
db.articles.find({ tags: { $size: 3 } })                       // exactly 3 elements (can't use the index)
JavaScript

In explain output, the IXSCAN stage reports isMultiKey: true and lists the array paths in multiKeyPaths.

Arrays of subdocuments and $elemMatch

This is where the real interview question lives. Store each order’s line items as an array of subdocuments:

db.orders.insertOne({
  _id: 500,
  items: [
    { sku: "A1", qty: 1,  price: 200 },
    { sku: "B2", qty: 10, price: 5 }
  ]
})
db.orders.createIndex({ "items.sku": 1, "items.qty": 1 })
JavaScript

Now find orders that contain at least 5 units of A1:

db.orders.find({ "items.sku": "A1", "items.qty": { $gte: 5 } })
// returns order 500
JavaScript

That’s wrong. Order 500 has only 1 unit of A1. It matched because the two conditions are checked independently against the array: some element has sku: "A1" (the first), and some element has qty >= 5 (the second). Nothing requires them to be the same element.

$elemMatch ties the conditions to one element:

db.orders.find({ items: { $elemMatch: { sku: "A1", qty: { $gte: 5 } } } })
// no results
JavaScript

This is a correctness issue first. Performance comes second: with $elemMatch, MongoDB can also combine the index bounds on items.sku and items.qty more tightly, because it knows they apply to the same element. Without it, the planner can only bound one of the fields and has to check the other after fetching.

For a single condition on a single field you don’t need $elemMatch. { "items.sku": "A1" } is fine.

The parallel arrays rule

A compound index can include several fields, but for any single document at most one of the indexed fields can be an array. Otherwise MongoDB would have to store the cartesian product of both arrays as keys. Try it:

db.posts.createIndex({ tags: 1, authors: 1 })
db.posts.insertOne({ tags: ["a", "b"], authors: ["x", "y"] })
// MongoServerError: cannot index parallel arrays [authors] [tags]
JavaScript

The index creation itself succeeds while no document has both as arrays. The insert fails. That’s a nasty surprise if your index was built on data where authors was always a single string, and a new feature starts writing an array.

Fields inside the same array of subdocuments don’t count as parallel: { "items.sku": 1, "items.qty": 1 } is fine, because both paths come from the same array.

Ways around the rule: index the two fields separately, keep one of the fields scalar, or restructure, for example into an array of { tag, author } pairs if that’s what you actually query.

Costs you should mention

Every element is a key. A document with a 2,000-element followerIds array adds 2,000 entries to that index. Inserting the document writes all 2,000; pushing one more element writes one more key; replacing the array rewrites them. Large multikey indexes eat RAM (the working set of indexes should fit in memory) and slow writes.

Two other limits worth knowing:

  • Covered queries. A multikey index can’t fully cover a query on the array field, because a single key doesn’t tell MongoDB what the whole array was. Expect a FETCH stage.
  • Sorting. Sorting by an array field uses the minimum element for ascending and the maximum for descending. That’s well defined, but rarely what people intend, so sorting by an array field is usually a design smell.

Updating array elements

Multikey indexes stay in sync automatically, but the way you update arrays affects how much index work happens. The positional operators target specific elements:

// change the quantity of the A1 line only
db.orders.updateOne({ _id: 500, "items.sku": "A1" }, { $set: { "items.$.qty": 3 } })

// apply a discount to every line priced over 100
db.orders.updateOne({ _id: 500 },
  { $mul: { "items.$[big].price": 0.9 } },
  { arrayFilters: [ { "big.price": { $gt: 100 } } ] })
JavaScript

The $ operator updates the first element matched by the filter; $[identifier] with arrayFilters updates every element matching a condition. Both change only the affected keys in the index, which is cheaper than rewriting the whole array with $set: { items: [...] }.

Practical example: tag search with a filter

A blog lists published articles with a given tag, newest first:

db.articles.find({ status: "published", tags: "mongodb" }).sort({ publishedAt: -1 }).limit(20)
JavaScript

Following the equality, sort, range rule, both status and tags are equalities, so the index is:

db.articles.createIndex({ status: 1, tags: 1, publishedAt: -1 })
JavaScript

Only tags is an array, so the parallel arrays rule isn’t violated. The index jumps to published plus mongodb and reads in date order. Explain should show totalKeysExamined around 20.

Interview practice

A recruitment app stores candidates as { name, skills: [{ name: "java", years: 2 }, { name: "go", years: 6 }] }. The query to find “candidates with at least 5 years of Java” is find({ "skills.name": "java", "skills.years": { $gte: 5 } }) and returns a candidate with 2 years of Java and 6 years of Go. Why, and what’s the right query and index?

Answer

The two conditions are evaluated separately against the skills array. The candidate has an element with name: "java" and a different element with years >= 5 (Go), so the document matches. MongoDB is doing exactly what the query says; the query doesn’t say “the same skill”.

The right query uses $elemMatch so both conditions must hold for one element:

db.candidates.find({ skills: { $elemMatch: { name: "java", years: { $gte: 5 } } } })
JavaScript

For the index, { "skills.name": 1, "skills.years": 1 } works. Both paths are in the same array, so it isn’t a parallel arrays problem, and with $elemMatch the planner can bound skills.name to "java" and skills.years to [5, Infinity] on the same key, which makes the scan tight. I’d verify with explain that the bounds include both fields and that totalDocsExamined is close to nReturned. I’d also note that if candidates can have hundreds of skills, the index grows with every element, which is fine at that size but worth monitoring.

Further reading

More in MongoDB

read ✓MongoDB · easy

MongoDB Compound Indexes and Query Order

How field order in a MongoDB compound index decides which queries it serves, with the equality, sort, range rule worked through step by step.

~6 min readread →
read ✓MongoDB · hard

MongoDB Explain Plans and Examined Documents

How to read MongoDB explain output: the plan stages, keys and documents examined versus returned, and what each pattern tells you to fix.

~6 min readread →
read ✓MongoDB · mid

MongoDB Partial Indexes and Subset Queries

How MongoDB partial indexes shrink an index to the documents you query, when the planner can use them, and how they give conditional uniqueness.

~5 min readread →
esc