Arrays and Embedded Documents
Before you start
You need: querying (Article 03).
Time: about 45 minutes, plus the practice.
Learning objective
Query and modify arrays and embedded documents correctly, including the cases where an intuitive query returns the wrong documents.
Topics
- Embedded documents
- Querying arrays
$elemMatch- Array update operators
- The positional operators
- Array projection
- Size and existence
- Diagnosing array queries
Embedded documents
{
_id: ObjectId("..."),
schoolId: 1,
name: "Ravi Kumar",
rollNumber: "NCA-2024-0012",
address: {
line1: "12 MG Road",
city: "Hyderabad",
state: "Telangana",
pinCode: "500001"
},
parent: {
name: "Suresh Kumar",
phone: "9951510727",
email: "suresh@example.com"
}
}
db.students.find({ "address.city": "Hyderabad" })
db.students.find({ "parent.phone": "9951510727" })
db.students.find({ "address.pinCode": { $regex: "^5000" } })
Dot notation must be quoted. { address.city: "..." } is a JavaScript syntax error.
Updating one field leaves the rest intact:
db.students.updateOne(
{ _id: id },
{ $set: { "address.city": "Bengaluru" } }
)
Setting the whole subdocument replaces it entirely:
// Destroys line1, state and pinCode
db.students.updateOne({ _id: id }, { $set: { address: { city: "Bengaluru" } } })
Same trap as replaceOne at the document level, one level down — and it is easier to write by accident, because passing a partial object from an API request looks reasonable.
Matching a whole subdocument
db.students.find({ address: { city: "Hyderabad", pinCode: "500001" } })
That matches only documents whose address has exactly these two fields, in this order. An address with a line1 does not match; the same two fields in the other order does not match either.
Exact subdocument matching is almost never what you want. Use dot notation:
db.students.find({ "address.city": "Hyderabad", "address.pinCode": "500001" })
Querying arrays
{
_id: ObjectId("..."),
name: "Ravi Kumar",
subjects: ["Mathematics", "Science", "English"],
results: [
{ subject: "Mathematics", marks: 87, isAbsent: false },
{ subject: "Science", marks: 45, isAbsent: false },
{ subject: "English", marks: null, isAbsent: true }
]
}
db.students.find({ subjects: "Mathematics" }) // array CONTAINS the value
db.students.find({ subjects: ["Mathematics", "Science"] }) // array EQUALS exactly, in order
db.students.find({ subjects: { $all: ["Mathematics", "Science"] } }) // contains both
db.students.find({ subjects: { $in: ["Physics", "Chemistry"] } }) // contains either
A bare value queries membership; an array literal queries exact equality including order. That distinction catches people constantly.
Arrays of subdocuments use dot notation:
db.students.find({ "results.subject": "Mathematics" })
db.students.find({ "results.marks": { $gt: 80 } })
The trap: conditions match across different elements
db.students.find({ "results.subject": "Science", "results.marks": { $gt: 80 } })
This matches Ravi Kumar, whose Science mark is 45.
MongoDB checks each condition against the array as a whole: some element has subject: "Science" (the second), and some element has marks > 80 (the first). They need not be the same element.
The query reads as "students who scored above 80 in Science" and returns students who scored above 80 in anything and also take Science. On a report of high performers, that is wrong data with no error.
$elemMatch
db.students.find({
results: {
$elemMatch: { subject: "Science", marks: { $gt: 80 } }
}
})
$elemMatch requires a single element to satisfy every condition. Ravi Kumar is now correctly excluded.
Use $elemMatch whenever two or more conditions must hold for the same array element. A single condition does not need it:
db.students.find({ "results.marks": { $gt: 80 } }) // fine
db.students.find({ results: { $elemMatch: { marks: { $gt: 80 } } } }) // equivalent
It also works on arrays of scalars:
db.students.find({ scores: { $elemMatch: { $gte: 80, $lte: 90 } } })
Without it, { scores: { $gte: 80, $lte: 90 } } matches a document with scores [95, 20] — one element satisfies each condition separately.
This is the single most important operator in this article. A report built without it is quietly wrong.
Array update operators
db.students.updateOne({ _id: id }, { $push: { subjects: "Computer Science" } })
db.students.updateOne({ _id: id }, {
$push: { subjects: { $each: ["Physics", "Chemistry"] } }
})
db.students.updateOne({ _id: id }, { $addToSet: { subjects: "Mathematics" } })
db.students.updateOne({ _id: id }, { $pull: { subjects: "English" } })
db.students.updateOne({ _id: id }, {
$pull: { results: { subject: "English" } }
})
db.students.updateOne({ _id: id }, { $pop: { subjects: 1 } }) // 1 last, -1 first
| Operator | Does |
|---|---|
$push | Append, allowing duplicates |
$addToSet | Append only if absent |
$each | Push several |
$pull | Remove every element matching a condition |
$pullAll | Remove specific values |
$pop | Remove the first or last |
$push allows duplicates; $addToSet does not. Using $push for a set of subjects produces ["Mathematics", "Mathematics"] after a retried request.
db.students.updateOne({ _id: id }, {
$push: {
results: {
$each: [{ subject: "History", marks: 72, isAbsent: false }],
$sort: { marks: -1 },
$slice: 10
}
}
})
$sort and $slice together maintain a bounded, ordered array — a top-ten list that stays at ten without a separate cleanup.
All of these are atomic. Reading an array, modifying it in application code and writing it back loses one of two concurrent updates; $push cannot.
The positional operators
Updating a specific element inside an array.
// $ — the FIRST element matched by the query
db.students.updateOne(
{ _id: id, "results.subject": "Science" },
{ $set: { "results.$.marks": 78 } }
)
The query must include a condition on the array, or $ has nothing to bind to:
The positional operator did not find the match needed from the query
// $[] — EVERY element
db.students.updateOne(
{ _id: id },
{ $set: { "results.$[].verified": true } }
)
// $[identifier] — every element matching a filter
db.students.updateMany(
{ schoolId: 1 },
{ $set: { "results.$[elem].isAbsent": true, "results.$[elem].marks": null } },
{ arrayFilters: [{ "elem.marks": { $lt: 0 } }] }
)
| Operator | Updates |
|---|---|
$ | The first matching element |
$[] | Every element |
$[identifier] | Every element matching arrayFilters |
$ updates only the first match. A student with two Science entries gets one corrected and one not — silently. Use $[elem] with arrayFilters when several elements can match.
Nested arrays need the filtered form:
db.students.updateOne(
{ _id: id },
{ $set: { "terms.$[term].results.$[res].marks": 78 } },
{ arrayFilters: [{ "term.name": "Mid Term" }, { "res.subject": "Science" }] }
)
Array projection
db.students.find(
{ "results.subject": "Science" },
{ name: 1, "results.$": 1 }
)
results.$ returns only the first matching element, not the whole array.
db.students.find({}, { results: { $slice: 3 } }) // first three
db.students.find({}, { results: { $slice: -3 } }) // last three
db.students.find({}, { results: { $slice: [10, 5] } }) // skip 10, take 5
db.students.find({}, {
name: 1,
results: { $elemMatch: { subject: "Science" } }
})
$elemMatch in a projection returns the first element matching its own condition — independent of the query filter, unlike results.$.
Projecting an array subset matters for size. A student with two hundred attendance records should not have all of them fetched to display three.
Size and existence
db.students.find({ subjects: { $size: 3 } }) // EXACTLY 3
db.students.find({ subjects: { $exists: true } })
db.students.find({ subjects: [] }) // empty array
db.students.find({ subjects: { $ne: [] } })
$size takes only an exact number. There is no $size: { $gt: 3 }:
// "more than 3 elements" — check whether index 3 exists
db.students.find({ "subjects.3": { $exists: true } })
Arrays are zero-indexed, so element 3 existing means at least four elements. Ugly, and it is the standard idiom.
For anything more complex, $expr with $size works but cannot use an index:
db.students.find({ $expr: { $gt: [{ $size: "$subjects" }, 3] } })
A frequently queried array length should be stored as a field and maintained alongside the array — subjectCount updated with $inc in the same operation as the $push.
null and missing in arrays
db.students.find({ "results.marks": null })
That matches documents where any element has marks: null, where an element is missing marks entirely, and — confusingly — where results itself does not exist.
db.students.find({
results: { $elemMatch: { marks: null, isAbsent: true } }
})
Being explicit about both fields is the only reliable way to find genuinely absent results.
Embedding versus referencing
// Embedded — one read, atomic updates, bounded size
{
_id: ObjectId("..."),
name: "Ravi Kumar",
address: { city: "Hyderabad", pinCode: "500001" },
results: [
{ subject: "Mathematics", marks: 87 },
{ subject: "Science", marks: 72 }
]
}
// Referenced — separate collection, joined with $lookup or a second query
// students
{ _id: ObjectId("s1"), name: "Ravi Kumar" }
// examResults
{ _id: ObjectId("r1"), studentId: ObjectId("s1"), subject: "Mathematics", marks: 87 }
| Embed when | Reference when |
|---|---|
| Always read together | Queried independently |
| Bounded, small | Unbounded growth |
| Updated with the parent | Updated separately and often |
| Not shared | Shared by several parents |
The unbounded-array problem is the one to watch. A document has a 16 MB limit, and an array that grows without limit — attendance records, audit entries, log lines — eventually hits it. The failure arrives years in, on the busiest document, and it is not recoverable in place.
Large arrays are also slow to update: MongoDB rewrites the whole document, so appending to a 5,000-element array rewrites all 5,000.
Rule of thumb: embed a few, reference many. An address and a parent are embedded; attendance over five years is a separate collection.
For a bounded subset plus full history, keep both:
{
_id: ObjectId("..."),
name: "Ravi Kumar",
recentResults: [ /* last 5, maintained with $slice */ ],
resultCount: 47
}
The recent five serve the profile page from one read; the full history lives in its own collection.
Diagnosing array queries
| Symptom | Cause |
|---|---|
| Too many documents returned | Two conditions matching different elements — needs $elemMatch |
| Exact array match returns nothing | Array literal compares order and length |
$ does not update | The query has no condition on the array |
| Only one element updated | $ updates the first match — use $[elem] |
| Subdocument fields disappeared | $set on the whole subdocument |
| Duplicates appearing | $push where $addToSet was meant |
$size comparison fails | $size takes an exact number only |
| Document too large | An unbounded embedded array |
db.students.findOne({ _id: id }, { results: 1 })
Look at one document before debugging a query. Array problems are usually visible immediately — a nested array where a flat one was assumed, or a field name differing between elements.
Compass's Schema tab reports array field types per element, which finds the element with marks as a string among 500 with numbers.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
| A query on two array conditions matches wrongly | Conditions matched different elements | Use $elemMatch |
$push created a nested array | Used $push with an array value | $each |
| Update changed the wrong array element | Positional $ matched the first | Use filtered positional $[<id>] |
| Document exceeds 16MB | Unbounded array growth | Move to a separate collection |
| Dot-notation query returns nothing | Wrong path, or the array level was skipped | Check the exact shape |
Two conditions on an array match if any element satisfies each separately. $elemMatch requires one element to satisfy both.
Common mistakes
- Two conditions on one array without
$elemMatch - An array literal used for a membership query
- Exact subdocument matching instead of dot notation
$seton a whole subdocument, destroying its other fields$pushwhere$addToSetbelongs$used where several elements match$with no array condition in the query- Expecting
$sizeto accept a comparison { "array.field": null }matching more than intended- Unbounded embedded arrays
- Fetching a whole array to display three elements
- Read-modify-write on an array instead of an atomic operator
Practice
The course exercise is model an embedded structure.
- Insert students with an embedded
addressand aresultsarray of subdocuments. - Query
{ "results.subject": "Science", "results.marks": { $gt: 80 } }on a student whose Science mark is 45. Confirm the wrong match. - Rewrite with
$elemMatchand confirm the correct result. - Query
{ scores: { $gte: 80, $lte: 90 } }on[95, 20]. Confirm the false match, then fix it. - Query
{ subjects: ["Mathematics", "Science"] }against a document with those two in the other order. Confirm no match. - Query the same with
$alland confirm it matches. - Update
{ $set: { address: { city: "Bengaluru" } } }. Read the document and list what was lost. Redo it with dot notation. - Match an address by whole subdocument with only two of its fields. Confirm no match.
$pushthe same subject twice. Then use$addToSetand compare.- Update a specific result with
$and no array condition in the query. Record the error. - Give a student two Science results. Update with
$and confirm only one changed. Fix it with$[elem]andarrayFilters. - Project only the matching result with
results.$, then with$elemMatch. Compare when the query filter and the projection condition differ. - Find students with more than three subjects using
"subjects.3": { $exists: true }. - Try
{ subjects: { $size: { $gt: 3 } } }and record the error. - Push 20,000 elements into one document's array and measure the update time as it grows. Check the document size.
- Restructure it as a referenced collection and compare.
Exercises 2, 11 and 15 correspond to a wrong report, a partial update and a document that eventually stops working.
You can now
- Query and update arrays and embedded documents
- Use
$elemMatchwhen both conditions must hit one element - Add and remove array items correctly
- Update a specific array element
- Say why unbounded arrays are a modelling error
Review questions
- Why does
{ "results.subject": "Science", "results.marks": { $gt: 80 } }return the wrong students? - What is the difference between
{ subjects: "Maths" }and{ subjects: ["Maths"] }? - When does
$update the wrong element, and what replaces it? - What limits how large an embedded array can grow?
Next: Data modelling