Querying arrays
A document field can hold an array, and querying one has a friendly surprise built in: when you filter an array field against a plain value, MongoDB checks every element and matches if any of them satisfies the condition. That single rule makes simple array queries read exactly like scalar ones. The subtleties show up only when you need several conditions to hold on the same element, which is where $elemMatch earns its keep.
For this lesson each member carries a borrowed array of book titles, a scores array of numbers, and some carry a tags array of interest labels. Every output below was captured on mongo 8.3.11, mongosh 2.11.1 and Node driver 7.7.0 (Python, Go and Rust tabs were each run once in a scratch program on the same server).
The data we query
Section titled “The data we query”The data is chosen so that different filters give different answers - that is how you prove an operator does what you think:
db.members.insertMany([ { name: "Ada", borrowed: ["Compilers"], scores: [70, 95], tags: ["js", "backend"] }, { name: "Grace", borrowed: ["Compilers", "Algorithms", "Databases"], scores: [70, 85, 95], tags: ["backend"] }, { name: "Linus", borrowed: [], scores: [], tags: [] }, { name: "Margaret", borrowed: ["Algorithms", "Compilers"], scores: [88], tags: ["js"] }, { name: "Edsger", borrowed: ["Databases"], scores: [60, 75] }, // no tags field { name: "Barbara", borrowed: "Compilers", scores: 85 }, // scalars, not arrays]);globalThis.names = (f) => db.members.find(f, { _id: 0, name: 1 }).sort({ name: 1 }).toArray().map((d) => d.name).join(", ");assert.strictEqual(db.members.countDocuments(), 6);await db.collection("members").insertMany([ { name: "Ada", borrowed: ["Compilers"], scores: [70, 95], tags: ["js", "backend"] }, { name: "Grace", borrowed: ["Compilers", "Algorithms", "Databases"], scores: [70, 85, 95], tags: ["backend"] }, { name: "Linus", borrowed: [], scores: [], tags: [] }, { name: "Margaret", borrowed: ["Algorithms", "Compilers"], scores: [88], tags: ["js"] }, { name: "Edsger", borrowed: ["Databases"], scores: [60, 75] }, { name: "Barbara", borrowed: "Compilers", scores: 85 },]);if ((await db.collection("members").countDocuments()) !== 6) throw new Error("expected 6");Matching a single element
Section titled “Matching a single element”To ask “who currently has Compilers checked out?”, filter the array field by the value as if it were scalar. MongoDB looks inside the array for you:
console.log("borrowed 'Compilers' :", names({ borrowed: "Compilers" }));console.log("borrowed ['Compilers'] (whole array):", names({ borrowed: ["Compilers"] }));console.log("borrowed ['Algorithms','Compilers'] :", names({ borrowed: ["Algorithms", "Compilers"] }));console.log("borrowed ['Compilers','Algorithms'] :", names({ borrowed: ["Compilers", "Algorithms"] }));assert.strictEqual(names({ borrowed: "Compilers" }), "Ada, Barbara, Grace, Margaret");const docs = await db.collection("members") .find({ borrowed: "Compilers" }) .toArray();console.log(docs.map((d) => d.name).sort());if (docs.length !== 4) throw new Error("expected 4");docs = list(db.members.find({"borrowed": "Compilers"}))cur, err := coll.Find(ctx, bson.M{"borrowed": "Compilers"})if err != nil { return err}var docs []bson.Mif err := cur.All(ctx, &docs); err != nil { return err}let mut cursor = coll .find(doc! { "borrowed": "Compilers" }) .await?;Any member whose array contains that title matches, no matter where it sits in the list - and, note, Barbara too, whose borrowed is the plain string "Compilers" (a scalar equal to the value matches as well). Handing the filter a whole array instead means exact array equality, order included:
borrowed 'Compilers' : Ada, Barbara, Grace, Margaretborrowed ['Compilers'] (whole array): Adaborrowed ['Algorithms','Compilers'] : Margaretborrowed ['Compilers','Algorithms'] :["Compilers"] matched only Ada, whose array is exactly that; ["Compilers","Algorithms"] matched nobody because Margaret’s array is stored in the opposite order.
Several conditions on the same element: $elemMatch
Section titled “Several conditions on the same element: $elemMatch”Here is the trap. Suppose scores holds an array of numbers and you want documents with an element greater than 80 and less than 90. Writing { scores: { $gt: 80, $lt: 90 } } does not mean that - it matches if some element is over 80 and some (possibly different) element is under 90. To pin both conditions to one element, use $elemMatch. The seed has two members whose arrays only look alike, Ada [70, 95] and Grace [70, 85, 95]:
console.log("plain {$gt: 80, $lt: 90} :", names({ scores: { $gt: 80, $lt: 90 } }));console.log("$elemMatch {$gt: 80, $lt: 90} :", names({ scores: { $elemMatch: { $gt: 80, $lt: 90 } } }));assert.strictEqual(names({ scores: { $gt: 80, $lt: 90 } }).includes("Ada"), true); // 95 is >80 and 70 is <90: two DIFFERENT elementsassert.strictEqual(names({ scores: { $elemMatch: { $gt: 80, $lt: 90 } } }), "Grace, Margaret");const docs = await db.collection("members").find({ scores: { $elemMatch: { $gt: 80, $lt: 90 } }}).toArray();console.log(docs.map((d) => d.name).sort());if (docs.length !== 2) throw new Error("expected Grace and Margaret");docs = list(db.members.find({ "scores": {"$elemMatch": {"$gt": 80, "$lt": 90}}}))cur, err := coll.Find(ctx, bson.M{ "scores": bson.M{"$elemMatch": bson.M{"$gt": 80, "$lt": 90}},})if err != nil { return err}var docs []bson.Mif err := cur.All(ctx, &docs); err != nil { return err}let mut cursor = coll .find(doc! { "scores": { "$elemMatch": { "$gt": 80, "$lt": 90 } } }) .await?;The two forms return different people:
plain {$gt: 80, $lt: 90} : Ada, Barbara, Grace, Margaret$elemMatch {$gt: 80, $lt: 90} : Grace, MargaretOnly Grace (85) and Margaret (88) have one element in the 81 to 89 band. The plain form also returned Ada, whose array [70, 95] has no element in the band - 95 satisfied > 80 and, separately, 70 satisfied < 90 - and Barbara, whose scores is the scalar 85 (a scalar is tested against both conditions directly).
The same rule applies to arrays of sub-documents. With orders whose items hold { price, qty }, “an item over 1000 with quantity of at least 2”:
db.orders.insertMany([ { _id: 1, items: [{ sku: "a", price: 1500, qty: 1 }, { sku: "b", price: 20, qty: 5 }] }, { _id: 2, items: [{ sku: "c", price: 1500, qty: 3 }] },]);const ids = (f) => db.orders.find(f, { _id: 1 }).toArray().map((d) => d._id);console.log("plain dotted paths :", ids({ "items.price": { $gt: 1000 }, "items.qty": { $gte: 2 } }));console.log("$elemMatch :", ids({ items: { $elemMatch: { price: { $gt: 1000 }, qty: { $gte: 2 } } } }));console.log("$all of 2 $elemMatch:", ids({ items: { $all: [{ $elemMatch: { price: { $gt: 1000 } } }, { $elemMatch: { qty: { $gte: 2 } } }] } }));assert.strictEqual(ids({ items: { $elemMatch: { price: { $gt: 1000 }, qty: { $gte: 2 } } } }).join(), "2");plain dotted paths : [ 1, 2 ]$elemMatch : [ 2 ]$all of 2 $elemMatch: [ 1, 2 ]Order 1 has an expensive item (qty 1) and a different, cheap item with qty 5: the plain form wrongly matches it; $elemMatch correctly returns only order 2. And $all wrapping two separate $elemMatch clauses is not a fix - it is again “some element for each condition”, so order 1 matched.
Requiring several values: $all
Section titled “Requiring several values: $all”$all matches when the array contains every listed value, in any order. It is the AND of membership. “Who has borrowed both Algorithms and Compilers at once?” is exactly this:
console.log("borrowed $all [Algorithms, Compilers]:", names({ borrowed: { $all: ["Algorithms", "Compilers"] } }));console.log("tags $all [js, backend] :", names({ tags: { $all: ["js", "backend"] } }));console.log("tags $in [js, backend] :", names({ tags: { $in: ["js", "backend"] } }));console.log("borrowed $all [] :", names({ borrowed: { $all: [] } }));assert.strictEqual(names({ borrowed: { $all: ["Algorithms", "Compilers"] } }), "Grace, Margaret");const docs = await db.collection("members").find({ borrowed: { $all: ["Algorithms", "Compilers"] }}).toArray();console.log(docs.map((d) => d.name).sort());if (docs.length !== 2) throw new Error("expected 2");docs = list(db.members.find({ "borrowed": {"$all": ["Algorithms", "Compilers"]}}))cur, err := coll.Find(ctx, bson.M{ "borrowed": bson.M{"$all": bson.A{"Algorithms", "Compilers"}},})if err != nil { return err}var docs []bson.Mif err := cur.All(ctx, &docs); err != nil { return err}let mut cursor = coll .find(doc! { "borrowed": { "$all": ["Algorithms", "Compilers"] } }) .await?;Only members holding both titles are returned; having one is not enough. Grace matches too - $all ignores extra elements and order - and $all is AND where $in is OR:
borrowed $all [Algorithms, Compilers]: Grace, Margarettags $all [js, backend] : Adatags $in [js, backend] : Ada, Grace, Margaretborrowed $all [] :An empty $all: [] matches nothing (not “everything”), a small trap when the list is built dynamically.
Filtering by length: $size
Section titled “Filtering by length: $size”$size matches arrays of an exact length. “Who currently has exactly two books out?” is a one-operator query:
console.log("borrowed $size 2 :", names({ borrowed: { $size: 2 } }));console.log("borrowed $size 0 :", names({ borrowed: { $size: 0 } }));console.log("tags $size 0 :", names({ tags: { $size: 0 } }), "| tags missing:", names({ tags: { $exists: false } }));console.log("more than one book :", names({ "borrowed.1": { $exists: true } }));let err;try { db.members.find({ borrowed: { $size: { $gt: 1 } } }).toArray(); } catch (e) { err = e; }console.log(err.name, err.code, err.message);assert.strictEqual(err.code, 2);const docs = await db.collection("members") .find({ borrowed: { $size: 2 } }) .toArray();console.log(docs.map((d) => d.name));if (docs.length !== 1) throw new Error("expected Margaret only");docs = list(db.members.find({"borrowed": {"$size": 2}}))cur, err := coll.Find(ctx, bson.M{"borrowed": bson.M{"$size": 2}})if err != nil { return err}var docs []bson.Mif err := cur.All(ctx, &docs); err != nil { return err}let mut cursor = coll .find(doc! { "borrowed": { "$size": 2 } }) .await?;Members whose borrowed array has precisely two elements match. $size accepts only a literal number - a range is a real error - and the workaround for “more than one” is to test that the element at index 1 exists:
borrowed $size 2 : Margaretborrowed $size 0 : Linustags $size 0 : Linus | tags missing: Barbara, Edsgermore than one book : Grace, MargaretMongoServerError 2 Failed to parse $size. Expected a number in: $size: { $gt: 1 }Note the empty-vs-missing distinction: $size: 0 finds Linus (empty array), not the members whose tags field is absent.
Reaching an element by index
Section titled “Reaching an element by index”Array positions are addressable with a dotted numeric path: borrowed.0 is the first element, borrowed.1 the second, and so on. Use this to query the element at a known position - “whose first borrowed book is Algorithms?”:
console.log("borrowed.0 = Algorithms:", names({ "borrowed.0": "Algorithms" }));assert.strictEqual(names({ "borrowed.0": "Algorithms" }), "Margaret");borrowed.0 = Algorithms: MargaretThe same dot path works when the array holds sub-documents - reviews.0.rating reaches into the first review’s rating field, a pattern the next lesson explores in depth.
Getting back only the matching elements
Section titled “Getting back only the matching elements”A filter decides which documents come back, not which array elements. To trim the array, use a projection: the positional $ (the first element matched by the filter), $elemMatch in the projection, or $slice. The seed has Grace with three scores:
const sorted = (c) => c.sort({ name: 1 }).toArray();console.log("scores.$ :", sorted(db.members.find({ scores: { $gt: 80 } }, { _id: 0, name: 1, "scores.$": 1 })));console.log("$elemMatch proj :", db.members.find({ name: "Grace" }, { _id: 0, name: 1, scores: { $elemMatch: { $gt: 80 } } }).toArray());console.log("$slice 2 :", db.members.find({ name: "Grace" }, { _id: 0, name: 1, borrowed: { $slice: 2 } }).toArray());assert.deepStrictEqual(Array.from(db.members.findOne({ name: "Grace", scores: { $gt: 80 } }, { "scores.$": 1 }).scores), [85]);scores.$ : [ { name: 'Ada', scores: [ 95 ] }, { name: 'Barbara', scores: 85 }, { name: 'Grace', scores: [ 85 ] }, { name: 'Margaret', scores: [ 88 ] }]$elemMatch proj : [ { name: 'Grace', scores: [ 85 ] } ]$slice 2 : [ { name: 'Grace', borrowed: [ 'Compilers', 'Algorithms' ] } ]The positional $ returns the first element that satisfied the filter (95 for Ada, the first score over 80; 85 for Grace, though 95 also qualified); $elemMatch and $slice in a projection are independent of the filter. Positional $ requires the array to appear in the query.
In Compass: array filters go in the Filter field just like scalar ones, for example { borrowed: { $size: 2 } } (Compass documentation, mongodb.com/docs/compass/current/query/filter, lists $elemMatch examples too); expand a matching array in the document view to see each element.
Do arrays use indexes?
Section titled “Do arrays use indexes?”Yes - an index on an array field becomes a multikey index, with one entry per element. Creating { borrowed: 1 } and explaining the element query on our six documents:
db.members.createIndex({ borrowed: 1 });const e = db.members.find({ borrowed: "Compilers" }).explain("executionStats");const ix = e.queryPlanner.winningPlan.inputStage;console.log("stage:", ix.stage, "| isMultiKey:", ix.isMultiKey, "| keys", e.executionStats.totalKeysExamined, "| docs", e.executionStats.totalDocsExamined);assert.strictEqual(ix.isMultiKey, true);stage: IXSCAN | isMultiKey: true | keys 4 | docs 4The indexes module covers multikey rules (including “cannot index parallel arrays”).
Tips and gotchas
Section titled “Tips and gotchas”- Why
$elemMatchmatters: without it, multiple conditions on an array field are checked across all elements, so{ scores: { $gt: 80, $lt: 90 } }matched[70, 95](Ada).$elemMatchforces all conditions onto a single element, which is almost always what you meant. - A whole-array value means equality, not containment.
{ borrowed: ["Compilers"] }matched only the array that is exactly that, in that order. $allis order-independent and ignores extra elements;$all: []matches nothing.$size, by contrast, fixes the exact length.$sizetakes a literal number only (Failed to parse $size. Expected a number). For “more than one element” test"field.1": { $exists: true }.- A single-value filter on an array matches by containment - and also matches a scalar equal to that value (Barbara).
- Filters do not trim arrays. Use the positional
$, a projection$elemMatch, or$slice.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant asked for “members with a B-band score, between 80 and 90” will write the natural scalar filter:
🐛 Spot the bug
// AI comment: "students with a score between 80 and 90"const bBand = await db.collection("members").find({ scores: { $gt: 80, $lt: 90 } }).toArray();await sendCongratulations(bBand);Show the answer
On an array field the two conditions are satisfied independently, by different elements. On our data this returned four members - Ada [70, 95], Barbara 85, Grace [70, 85, 95] and Margaret [88] - although only Grace and Margaret have a score in the band. Ada would be congratulated for a score of 95 and 70 but no B. The fix pins both bounds to one element:
const bug = names({ scores: { $gt: 80, $lt: 90 } });const fixed = names({ scores: { $elemMatch: { $gt: 80, $lt: 90 } } });console.log("plain :", bug);console.log("$elemMatch :", fixed);assert.strictEqual(bug, "Ada, Barbara, Grace, Margaret");assert.strictEqual(fixed, "Grace, Margaret");plain : Ada, Barbara, Grace, Margaret$elemMatch : Grace, Margaret(Barbara’s scalar 85 is only dropped by $elemMatch because it is not an array element; if scalars and arrays are mixed in one field, normalise the field first - schema validation, later module, prevents it.)
Mental Model
Section titled “Mental Model”An array field is a set of independent values that the filter sees all at once. Each condition asks “does any element satisfy me?” and different conditions can be satisfied by different elements. $elemMatch groups conditions into a single question about one element; $all ANDs membership questions; $size and index paths ask about shape. The query selects documents; the projection ($, $elemMatch, $slice) selects elements.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
$elemMatch for multiple conditions | Forces every condition true on the same element, matching real intent almost always | Slightly more to write; you must think about “same element” up front |
Separate operators on the same field (no $elemMatch) | Shorter to write, reads like an ordinary scalar query | Each condition can pass on a different element, producing unintended matches (Ada above) - a common silent bug |
$all | Expresses “has every one of these values” | Ignores extra elements and order; empty list matches nothing |
Positional/length queries ($size, "field.0") | Targets a specific position or length directly in one call | Fragile to array reordering, and $size only accepts a literal value, not a range |
| Multikey index on the array | Element queries use IXSCAN (4 keys for 4 hits) | Index size and write cost grow with array length; restrictions on compound multikey indexes |
Common Misconceptions
Section titled “Common Misconceptions”- “A filter trims the returned array to the matching elements.” - it only decides whether the whole document is returned. Use
scores.$, a projection$elemMatch, or$slice. - “
borrowed.0has a fixed business meaning.” - array order changes on every$push/$pull; querying by value is sturdier. - “
$allwith several$elemMatchclauses pins one element.” - each clause can be satisfied by a different element (order 1 in the run above matched). - “
{ field: ['a'] }means contains ‘a’.” - it means the array equals['a']. - “
$sizecan take a comparison.” - it cannot:Failed to parse $size. Expected a number. - “
$size: 0finds documents without the field.” - it finds empty arrays only;$exists: falsefinds the absent ones.
Real-world example
The MongoDB manual’s array tutorial describes both behaviours we measured: a compound filter matches arrays whose “elements … in some combination satisfy the query conditions. One element can satisfy the greater than
15condition and another element can satisfy the less than20condition, or a single element can satisfy both”;$elemMatchis “so that at least one array element satisfies all the specified criteria” (Query an Array, consulted 2026-09-30).
Python/Go/Rust tabs mirror the verified shell/Node behaviour; each was run once in a scratch program against the same server and returned the same members (borrowed: "Compilers", $elemMatch, $all, $size) but they are not executed by the course’s automated checker.