Querying Embedded Documents and Arrays

One of MongoDB’s defining features is that a single document can contain nested objects and arrays, so a customer’s address or an order’s line items live inside the parent document instead of a separate table. Querying that nested data correctly requires a different mental model than SQL joins: you reach into embedded documents with dot notation, and you match array elements with operators like $elemMatch, $all, and $size. Get this wrong and your query either silently returns nothing, or worse, silently matches documents it shouldn’t. This lesson covers both cases in depth, including the internals of how MongoDB indexes and scans arrays.

Overview: How MongoDB Stores and Queries Nested Data

A MongoDB document is BSON (Binary JSON), and BSON allows a field’s value to be another full document (an embedded document) or a list of values (an array). Unlike a SQL row, where every column holds a single scalar, a MongoDB field can hold an entire object graph. A users document might look like this:

{
  _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),
  name: "Priya Sharma",
  address: { street: "12 MG Road", city: "Bengaluru", zip: "560001" },
  tags: ["premium", "verified"]
}

Here address is an embedded document (a single nested object) and tags is an array of scalars. An orders document can go a level further and hold an array of embedded documents — a list of line items, each itself a small object:

{
  _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0e2"),
  customer: "Rahul Verma",
  items: [
    { product: "Widget", qty: 2, price: 9.99 },
    { product: "Gadget", qty: 10, price: 4.99 }
  ]
}

To reach into any of this, MongoDB uses dot notation: a string field path like "address.city" or "items.product", quoted because it contains a dot. When the field you’re drilling into is a single embedded document, dot notation behaves exactly like you’d expect — it targets one specific nested field. When the field is an array, dot notation behaves differently: MongoDB checks whether any element of the array satisfies the condition, not a specific index. This is the single most important idea in this lesson, and it’s also the source of the most common bug, covered below in Common Mistakes.

Internally, when you index an array field, MongoDB builds a multikey index — one index entry per array element, all pointing back to the same document. This is what lets { tags: "wireless" } use an index efficiently even though tags holds multiple values per document. A compound index can include at most one array field; MongoDB rejects attempts to create a compound multikey index over two array fields at once, because the number of index entries would explode combinatorially (every combination of elements from both arrays).

Syntax

There’s no single “embedded query” method — you use the regular find() filter document, just with dot-notation paths and array-aware operators:

db.collection.find({
  "embeddedDoc.field": value,
  arrayField: { $elemMatch: { field1: value1, field2: { $gte: value2 } } },
  scalarArrayField: { $all: [value1, value2] },
  anotherArrayField: { $size: n }
});
Operator / Notation Purpose
"field.nestedField" Dot notation — reach into an embedded document, or into a field across all elements of an array
$elemMatch Require that a single array element satisfy all listed conditions together
$all Match arrays that contain every value listed, regardless of order or extra elements
$size Match arrays with an exact element count (accepts a literal number only, no range)
$ (projection) In the projection document, return only the first array element that matched the filter

Examples

Example 1: Querying a field inside an embedded document

db.users.find({ "address.city": "Bengaluru" });

Output:

[
  {
    _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),
    name: "Priya Sharma",
    address: { street: "12 MG Road", city: "Bengaluru", zip: "560001" },
    tags: [ "premium", "verified" ]
  }
]

Because address is a single embedded document (not an array), dot notation targets exactly that one nested field. If a compound index exists on "address.city", MongoDB uses it directly; check with .explain("executionStats") to confirm IXSCAN instead of COLLSCAN.

Example 2: Matching array contents with $all

db.products.find({ tags: { $all: ["wireless", "bluetooth"] } });

Output:

[
  {
    _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0f3"),
    name: "Portable Speaker",
    tags: [ "bluetooth", "wireless", "waterproof" ]
  }
]

A plain equality filter like { tags: "wireless" } would match any document where the tags array contains that one value. $all raises the bar: the array must contain every value in the list, in any order, alongside whatever other values it also holds. This is different from an exact match — a document with five tags including both wireless and bluetooth still matches.

Example 3: Matching an array of embedded documents with $elemMatch

db.orders.find({
  items: { $elemMatch: { product: "Widget", qty: { $gte: 5 } } }
});

Output:

[
  {
    _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0e9"),
    customer: "Anita Rao",
    items: [
      { product: "Widget", qty: 8, price: 9.99 },
      { product: "Gadget", qty: 1, price: 4.99 }
    ]
  }
]

This is the correct way to ask “does this order contain a line item that is both a Widget and has qty >= 5?” $elemMatch forces both conditions to be satisfied by the same array element. Rahul Verma’s earlier order (Widget qty 2, Gadget qty 10) correctly does not match this filter, because no single item satisfies both conditions at once.

How It Works Step by Step

  • MongoDB parses the filter document and identifies which fields (including dotted paths) it references.
  • If a usable index exists on that path — including a multikey index on an array field — the query planner performs an IXSCAN, walking only the relevant index entries instead of every document.
  • For a plain dotted path like "items.product", MongoDB checks the condition against each element of the array independently — an element matches if its product field satisfies the condition, with no requirement that other conditions in the filter come from the same element.
  • For $elemMatch, MongoDB instead evaluates all the conditions inside it as a single sub-filter, applied per array element — an element only counts as a match if it alone satisfies everything inside the $elemMatch.
  • Once candidate documents are found (via index or full collection scan), MongoDB fetches the full document, and — since indexes only narrow candidates — re-verifies the filter against the actual document if any part of the query isn’t fully covered by the index.
  • If a projection like { "items.$": 1 } is present, MongoDB trims the array in the result to only the first element that matched the query filter, rather than returning the whole array.

Common Mistakes

Mistake 1: Using dot notation with multiple conditions where you meant $elemMatch. This is the classic array cross-match bug.

// WRONG: matches ANY order where some item is a Widget AND some (possibly different) item has qty >= 5
db.orders.find({
  "items.product": "Widget",
  "items.qty": { $gte: 5 }
});

Because each dotted condition is checked against the array independently, an order with a Widget at qty 2 and a Gadget at qty 10 incorrectly matches — no single item satisfies both, but each condition finds a satisfying element somewhere in the array. Use $elemMatch to bind the conditions to one element, as shown in Example 3:

// CORRECT
db.orders.find({
  items: { $elemMatch: { product: "Widget", qty: { $gte: 5 } } }
});

Mistake 2: Comparing an ObjectId inside an array to a plain string. If a line item references a product by productId, and you read that id from a URL param or request body, it arrives as a string — not a BSON ObjectId.

// WRONG: productIdFromUrl is a string, items.productId is stored as ObjectId -- never matches
const productIdFromUrl = "64f1a2b3c4d5e6f7a8b9c0aa";
db.orders.find({ "items.productId": productIdFromUrl });
// CORRECT: convert to ObjectId first
const productIdFromUrl = "64f1a2b3c4d5e6f7a8b9c0aa";
db.orders.find({ "items.productId": new ObjectId(productIdFromUrl) });

Mistake 3: Trying to use a range operator with $size. $size only accepts a literal integer, not a comparison operator — this query silently returns nothing, not an error you’d expect.

// WRONG: $size does not accept $gte -- matches nothing, no error thrown
db.products.find({ tags: { $size: { $gte: 2 } } });
// CORRECT: use $expr with the aggregation $size operator for a range comparison
db.products.find({ $expr: { $gte: [{ $size: "$tags" }, 2] } });

Best Practices

  • Reach for $elemMatch any time a query on an array of embedded documents has more than one condition — it’s rarely correct to combine dotted array conditions without it.
  • Index the array field (or a specific sub-field like "items.product") when you filter on it regularly; check .explain("executionStats") to confirm you get IXSCAN, not COLLSCAN.
  • Keep embedded arrays bounded — a list of order line items or comment replies is fine, but an array that grows without limit (e.g., an activity log appended forever) belongs in its own collection, referenced by _id.
  • Remember only one array field per compound index; design compound indexes around a single multikey field plus scalar fields.
  • Always convert string ids to ObjectId with new ObjectId(str) before comparing against a stored _id or reference field, even inside nested arrays.
  • Use the $ projection operator (or $elemMatch projection) when you only need the matching array element back, not the entire array — this keeps result documents smaller.

Practice Exercises

  • Given a db.employees collection where each document has an embedded address object with a state field, write a query that returns employees living in "Texas".
  • Given a db.recipes collection where each document has an ingredients array of embedded objects with name and quantity fields, write a query that finds recipes containing an ingredient named "flour" with quantity greater than 2 — using the same array element for both conditions. Expect a result shape where the matching recipe documents are returned in full, each containing an ingredients array with at least one element satisfying both conditions.
  • Given a db.students collection with a courses array of strings, write a query that finds students enrolled in both "Algebra" and "Chemistry", then a second query that finds students enrolled in exactly 3 courses.

Summary

  • Dot notation ("field.nested") reaches into embedded documents; on array fields, each dotted condition is checked against elements independently, which can cause cross-matches across different elements.
  • $elemMatch requires multiple conditions to be satisfied by the same array element — use it whenever you filter an array of embedded documents on more than one field.
  • $all matches arrays containing every listed value in any order; plain equality on an array field matches if the array contains that single value anywhere.
  • $size matches an exact array length and only accepts a literal number; use $expr with the aggregation $size operator for range comparisons.
  • Indexing an array field creates a multikey index, with one entry per element; compound indexes can include at most one array field.
  • Always convert string ids to ObjectId before comparing against stored reference fields, including inside nested arrays.