Skip to content

Querying nested documents

Documents nest. A member can carry an embedded address object, and that object has its own fields. There are two distinct ways to query into a sub-document, and they behave very differently: you can reach a single inner field with dot notation, or you can match the whole sub-document at once. The first is forgiving; the second is an exact, order-sensitive comparison that surprises people. This lesson makes the difference concrete. 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 members below hold the same address in different shapes on purpose: Grace has city before zip, Ada has zip before city, Linus has an extra country field, and Alan stores the address as a plain string:

mongosh/nest-seed.js
db.members.insertMany([
{ name: "Grace", address: { city: "Cambridge", zip: "02139" }, contact: { email: "[email protected]", phone: "555-0101" },
reviews: [{ book: "Algorithms", rating: 3 }, { book: "Networks", rating: 5 }] },
{ name: "Margaret", address: { city: "Boston", zip: "02110" }, contact: { email: "[email protected]" },
reviews: [{ book: "Algorithms", rating: 5 }, { book: "Networks", rating: 3 }] },
{ name: "Ada", address: { zip: "02139", city: "Cambridge" }, reviews: [] }, // zip BEFORE city
{ name: "Linus", address: { city: "Cambridge", zip: "02139", country: "US" } }, // extra field
{ name: "Edsger" }, // no address
{ name: "Alan", address: "Cambridge, UK" }, // a string, not an object
]);
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);

To match on one field buried inside an embedded object, name the path with a dot: "address.city". The quotes are required because the key contains a dot. Here we find every member living in Cambridge:

mongosh/nest-dot.js
console.log("address.city = Cambridge :", names({ "address.city": "Cambridge" }));
console.log("address.zip $exists :", names({ "address.zip": { $exists: true } }));
console.log("address.city missing :", names({ "address.city": { $exists: false } }));
console.log("contact.phone missing :", names({ "contact.phone": { $exists: false } }));
assert.strictEqual(names({ "address.city": "Cambridge" }), "Ada, Grace, Linus");

The query reaches into address and matches only its city, ignoring whatever else the sub-document holds and however its fields are ordered - Ada (zip first) and Linus (extra country) both match alongside Grace. Dot paths chain as deep as your data goes and combine freely with operators:

address.city = Cambridge : Ada, Grace, Linus
address.zip $exists : Ada, Grace, Linus, Margaret
address.city missing : Alan, Edsger
contact.phone missing : Ada, Alan, Edsger, Linus, Margaret

Alan (whose address is a string, so address.city cannot exist) and Edsger (no address) are both “missing” for address.city; no error is raised for a path through a non-object.

Matching a whole sub-document: order matters

Section titled “Matching a whole sub-document: order matters”

You can also hand the address field an entire document as its value. This is not the same as a dotted query. It demands an exact match: the same fields, the same values, and the same order, with no extra fields allowed. Here we match the full address, in three forms:

mongosh/nest-whole.js
console.log("{ city, zip } :", names({ address: { city: "Cambridge", zip: "02139" } }));
console.log("{ zip, city } reversed :", names({ address: { zip: "02139", city: "Cambridge" } }));
console.log("{ zip } only :", names({ address: { zip: "02139" } }));
assert.strictEqual(names({ address: { city: "Cambridge", zip: "02139" } }), "Grace");
assert.strictEqual(names({ address: { zip: "02139" } }), "");

The same three people share a city and a zip, yet the whole-document form finds a different single person for each spelling:

{ city, zip } : Grace
{ zip, city } reversed : Ada
{ zip } only :

{ city, zip } matches only Grace, whose stored order is exactly that - not Ada (same values, reversed order) and not Linus (an extra country). Reverse the filter and it matches Ada instead. A partial sub-document ({ zip } alone) matches nobody. That is the order-sensitivity trap, and it is why dot notation is the safer default. The Go example uses bson.D, an ordered document: a Go bson.M is a map, and Go does not define map iteration order. In our run (Go 1.27.1, driver v2.9.1) a two-key bson.M happened to marshal in source order - it matched the right person 500 times out of 500 - but that is an implementation detail you should not rely on; bson.D is the guaranteed form. JavaScript objects, Python dicts and Rust doc! keep insertion order.

The most common real shape is an array whose elements are sub-documents. Say each member has a reviews array, each review a book and a rating. A plain dotted query like "reviews.rating" checks the condition across all reviews, so to require both fields on the same review you combine the dot path’s spirit with $elemMatch. Here we find members with a review of Algorithms rated at least 4 - Grace rated Algorithms 3 and Networks 5, Margaret the reverse:

mongosh/nest-array.js
console.log("plain dotted paths :", names({ "reviews.book": "Algorithms", "reviews.rating": { $gte: 4 } }));
console.log("$elemMatch :", names({ reviews: { $elemMatch: { book: "Algorithms", rating: { $gte: 4 } } } }));
console.log("reviews.0.book :", names({ "reviews.0.book": "Algorithms" }));
assert.strictEqual(names({ reviews: { $elemMatch: { book: "Algorithms", rating: { $gte: 4 } } } }), "Margaret");

Only members whose same review names that book and clears the rating bar match. The plain form also returned Grace, who rated Algorithms 3 but some other book 5:

plain dotted paths : Grace, Margaret
$elemMatch : Margaret
reviews.0.book : Grace, Margaret

reviews.0.book addresses the first review by index (fragile if the array is reordered).

Updating a nested field: the sibling-wiping trap

Section titled “Updating a nested field: the sibling-wiping trap”

The same whole-versus-path distinction decides what an update does. Setting one inner field by dot path keeps its siblings; setting the parent to a new document replaces it:

mongosh/nest-update.js
db.people.insertMany([
{ name: "Linus", address: { city: "Cambridge", zip: "02139", country: "US" } },
{ name: "Ada", address: { city: "Cambridge", zip: "02139", country: "US" } },
]);
db.people.updateOne({ name: "Linus" }, { $set: { "address.zip": "02140" } }); // by path
db.people.updateOne({ name: "Ada" }, { $set: { address: { zip: "02141" } } }); // by whole document
console.log(db.people.findOne({ name: "Linus" }, { _id: 0 }));
console.log(db.people.findOne({ name: "Ada" }, { _id: 0 }));
assert.strictEqual(db.people.findOne({ name: "Linus" }).address.city, "Cambridge");
assert.strictEqual("city" in db.people.findOne({ name: "Ada" }).address, false);
{
name: 'Linus',
address: { city: 'Cambridge', zip: '02140', country: 'US' }
}
{ name: 'Ada', address: { zip: '02141' } }

Ada lost city and country. Rule of thumb: query and update nested fields with dot paths.

A dotted path indexes like any field:

mongosh/nest-index.js
db.members.createIndex({ "address.city": 1 });
const e = db.members.find({ "address.city": "Cambridge" }).explain("executionStats");
console.log(e.queryPlanner.winningPlan.inputStage.stage, "keys", e.executionStats.totalKeysExamined, "docs", e.executionStats.totalDocsExamined);
assert.strictEqual(e.queryPlanner.winningPlan.inputStage.stage, "IXSCAN");
IXSCAN keys 3 docs 3

In Compass: the Filter field accepts dotted keys directly, for example { "address.city": "Cambridge" }; the Documents tab shows nested objects as expandable nodes, which makes it easy to confirm the exact field order before you attempt a whole-sub-document match (Compass documentation, query filter, lists { "school.name": "Northwestern" } as an embedded-field example).

  • Order sensitivity is the big one. Matching an embedded document by handing the field a full document compares the whole thing literally - same fields, same values, same order, no extras. Re-arrange the keys and the match silently disappears (Grace vs Ada above).
  • Dot notation is order-independent and partial. { "address.city": "Cambridge" } cares about one field and nothing else, which is why it is the everyday choice for embedded data.
  • Quote any key with a dot. address.city must be written as the string "address.city".
  • Arrays of sub-documents need $elemMatch for multi-field conditions, for the same reason scalar arrays do - otherwise the conditions spread across different elements.
  • A field whose name contains a dot cannot be reached by its plain name. With { "a.b": 1, a: { b: 2 } }, { "a.b": 2 } found the document and { "a.b": 1 } found nothing - the dotted string always means the path. Avoid dots in field names.
  • Update nested fields by path, not by handing over the parent (address.zip vs address: { zip }).

An assistant asked for “members in the 02139 zip” will often write a sub-document filter with just the field it cares about:

🐛 Spot the bug

// AI comment: "find members whose address has zip 02139"
const local = await db.collection("members")
.find({ address: { zip: "02139" } })
.toArray();
console.log(`${local.length} local members`); // prints 0
Show the answer

{ address: { zip: "02139" } } does not mean “address has a zip of 02139” - it means “address is exactly the document { zip: "02139" }”. On our data it matched nobody, although three members live in that zip (Ada, Grace and Linus). The code does not error; the reminder list is simply empty. The fix is the dot path:

mongosh/nest-zip-fix.js
const bug = names({ address: { zip: "02139" } });
const fixed = names({ "address.zip": "02139" });
console.log("sub-document form :", JSON.stringify(bug));
console.log("dot-path form :", fixed);
assert.strictEqual(bug, "");
assert.strictEqual(fixed, "Ada, Grace, Linus");
sub-document form : ""
dot-path form : Ada, Grace, Linus

A nested document is a path tree. Dot notation asks about one leaf of the tree, ignoring everything else; a sub-document filter asks whether a whole branch is byte-for-byte the value you handed over. The same split governs updates: "address.zip" edits a leaf, address: {...} replaces a branch. Ask “am I talking about a leaf or a branch?” before writing the filter.

ChoiceBenefitCost
Dot-notation query on a shallow, stable nested fieldEasy to read, indexes well - IXSCAN, 3 keys for 3 hits aboveYou must know the exact path in advance; a schema change means updating every query that references it
Deeply nested or per-document varying shapeFlexible for data whose shape differs document to documentHard to build a covering index for, worse selectivity, and queries must handle missing fields or differing types (Alan’s string address)
Matching a whole sub-document exactlyChecks “identical in every way” in one callSensitive to field order and extra fields (Ada, Linus missed), far narrower than dot notation
$set by dot pathChanges one leaf, keeps siblingsYou must remember the path for every field you touch
$set a whole sub-documentReplaces a branch in one stepDrops any sibling you forgot (Ada lost city and country)
  • “{ address: { zip: '02139' } } finds addresses with that zip.” - it needs the sub-document to equal it exactly; it matched nobody.
  • “Field order inside a sub-document does not matter.” - for whole-sub-document matching it does (Grace vs Ada).
  • “A dotted path errors if the parent is not an object.” - it just does not match (Alan’s string address).
  • “$set: { address: { zip } } updates the zip.” - it replaces the whole address.
  • “An index on a dotted path is second-class.” - address.city indexed and used like any field.
  • “Plain dotted conditions on an array of sub-documents pin one element.” - they spread across elements; use $elemMatch.

Real-world example

The MongoDB manual’s embedded-document tutorial states the rule we measured: “MongoDB does not recommend comparisons on embedded documents because the operations require an exact match of the specified <value> document, including the field order”, and for dot notation notes that “the field and nested field must be inside quotation marks” (Query on Embedded/Nested Documents, 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 (dot path: Grace and Ada; whole document: Grace only; $elemMatch: Margaret) but they are not executed by the course’s automated checker.

How do you match the city field inside an embedded address object?
What is special about matching a whole embedded sub-document, like { address: { city, zip } }?
Why does the Go example use bson.D for a whole-sub-document match?
To require book and rating on the SAME element of a reviews array, you use:
Linus has address { city, zip, country }. What does updateOne({ name: "Linus" }, { $set: { address: { zip: "02141" } } }) do to it?