Skip to main content
Published / updated

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
OperatorDoes
$pushAppend, allowing duplicates
$addToSetAppend only if absent
$eachPush several
$pullRemove every element matching a condition
$pullAllRemove specific values
$popRemove 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 } }] }
)
OperatorUpdates
$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 whenReference when
Always read togetherQueried independently
Bounded, smallUnbounded growth
Updated with the parentUpdated separately and often
Not sharedShared 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

SymptomCause
Too many documents returnedTwo conditions matching different elements — needs $elemMatch
Exact array match returns nothingArray literal compares order and length
$ does not updateThe 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 largeAn 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 seeCauseFix
A query on two array conditions matches wronglyConditions matched different elementsUse $elemMatch
$push created a nested arrayUsed $push with an array value$each
Update changed the wrong array elementPositional $ matched the firstUse filtered positional $[<id>]
Document exceeds 16MBUnbounded array growthMove to a separate collection
Dot-notation query returns nothingWrong path, or the array level was skippedCheck 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
  • $set on a whole subdocument, destroying its other fields
  • $push where $addToSet belongs
  • $ used where several elements match
  • $ with no array condition in the query
  • Expecting $size to 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.

  1. Insert students with an embedded address and a results array of subdocuments.
  2. Query { "results.subject": "Science", "results.marks": { $gt: 80 } } on a student whose Science mark is 45. Confirm the wrong match.
  3. Rewrite with $elemMatch and confirm the correct result.
  4. Query { scores: { $gte: 80, $lte: 90 } } on [95, 20]. Confirm the false match, then fix it.
  5. Query { subjects: ["Mathematics", "Science"] } against a document with those two in the other order. Confirm no match.
  6. Query the same with $all and confirm it matches.
  7. Update { $set: { address: { city: "Bengaluru" } } }. Read the document and list what was lost. Redo it with dot notation.
  8. Match an address by whole subdocument with only two of its fields. Confirm no match.
  9. $push the same subject twice. Then use $addToSet and compare.
  10. Update a specific result with $ and no array condition in the query. Record the error.
  11. Give a student two Science results. Update with $ and confirm only one changed. Fix it with $[elem] and arrayFilters.
  12. Project only the matching result with results.$, then with $elemMatch. Compare when the query filter and the projection condition differ.
  13. Find students with more than three subjects using "subjects.3": { $exists: true }.
  14. Try { subjects: { $size: { $gt: 3 } } } and record the error.
  15. Push 20,000 elements into one document's array and measure the update time as it grows. Check the document size.
  16. 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 $elemMatch when 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

  1. Why does { "results.subject": "Science", "results.marks": { $gt: 80 } } return the wrong students?
  2. What is the difference between { subjects: "Maths" } and { subjects: ["Maths"] }?
  3. When does $ update the wrong element, and what replaces it?
  4. What limits how large an embedded array can grow?

Next: Data modelling