Type something to search...
Working with Nested Documents and Dot Notation in MongoDB

Working with Nested Documents and Dot Notation in MongoDB

Nested documents are one of the first things that make MongoDB feel different from a relational database. A user's address, notification preferences, and billing details can live right inside the user document, shaped exactly the way your application thinks about them. No join tables, no mapping layer, just an object inside an object.

The catch is that nested data has two ways of being addressed, and they behave very differently. You can refer to a subdocument as a whole, or you can reach into it with dot notation and touch one field. Mixing those up is behind some of the most common MongoDB bugs: queries that silently return nothing, and updates that delete fields you never meant to touch.

This guide covers how to query nested fields, the difference between whole-document and dot-notation matching, safe update patterns, projection, indexing, working with nested data in aggregation and drivers, and the naming and design rules that keep deep structures manageable.

Sample Data

Here's a users collection with a couple of levels of nesting:

db.users.insertMany([
  {
    _id: 1,
    name: "Ada",
    address: {
      street: "12 Analytical Way",
      city: "London",
      postcode: "N1 9GU",
    },
    prefs: { theme: "dark", notifications: { email: true, sms: false } },
  },
  {
    _id: 2,
    name: "Grace",
    address: { city: "Arlington", street: "1 Compiler Ct", postcode: "22201" },
    prefs: { theme: "light", notifications: { email: false, sms: true } },
  },
  {
    _id: 3,
    name: "Linus",
    prefs: { theme: "dark" },
  },
]);

Note that the three documents aren't identical in shape. Grace's address fields are in a different order, and Linus has no address and no notification settings at all. That's normal in MongoDB, and it matters for the examples below.

Dot Notation Basics

Dot notation joins field names with periods to form a path. "address.city" means "the city field inside the address subdocument". Paths can go as deep as your data does: "prefs.notifications.email".

One syntax rule is non-negotiable: dotted paths must be quoted. In JavaScript, { address.city: "London" } is a syntax error. Write { "address.city": "London" }. Python dict keys are always strings, so {"address.city": "London"} is the natural form there.

Querying Nested Fields

Matching a Single Field

Dot notation is almost always what you want in a query:

db.users.find({ "address.city": "London" });
// Ada

db.users.find({ "prefs.notifications.sms": true });
// Grace

All the usual query operators work on nested paths:

db.users.find({ "address.postcode": { $regex: /^N1/ } });
db.users.find({ "prefs.notifications": { $exists: false } }); // Linus

Matching the Whole Subdocument

You can also query with an embedded document as the value:

db.users.find({
  address: { street: "12 Analytical Way", city: "London", postcode: "N1 9GU" },
});
// Ada

This is an exact match. The subdocument must contain exactly those fields, with exactly those values, in exactly that order. Try it for Grace with the fields in "logical" order:

db.users.find({
  address: { street: "1 Compiler Ct", city: "Arlington", postcode: "22201" },
});
// nothing: Grace's document stores city before street

And a partial subdocument doesn't match either:

db.users.find({ address: { city: "London" } });
// nothing: Ada's address has more fields than just city

Whole-document matching is brittle. Field order depends on how the document was written, which depends on your code, your driver, and sometimes the update history. Unless you really mean "exactly this object", use dot notation for each field:

db.users.find({ "address.city": "Arlington", "address.postcode": "22201" });
// Grace
Query styleMatches when
{ "address.city": "London" }address.city equals London, other fields don't matter
{ address: { city: "London" } }address is exactly { city: "London" }, nothing else

Nested Fields Inside Arrays

When a path passes through an array, dot notation applies to every element. If users had addresses: [ {...}, {...} ], then { "addresses.city": "London" } matches any user with at least one London address. Multiple conditions on the same array element require $elemMatch, which is covered in the guide to working with arrays in MongoDB.

Updating Nested Fields

This is where the dot really matters.

The Subdocument Overwrite Bug

Say you want to change Ada's city. Here's the version that looks right and isn't:

db.users.updateOne({ _id: 1 }, { $set: { address: { city: "Cambridge" } } });

db.users.findOne({ _id: 1 }, { address: 1 });
// { _id: 1, address: { city: "Cambridge" } }

Street and postcode are gone. $set replaced the entire address value with the new object. The same thing happens to prefs if you $set it to { theme: "light" }: notification settings disappear.

The correct version targets the path:

db.users.updateOne({ _id: 1 }, { $set: { "address.city": "Cambridge" } });
// { _id: 1, address: { street: "12 Analytical Way", city: "Cambridge", postcode: "N1 9GU" } }

This bug is especially easy to write from application code, because you often have an object like req.body.address and it's tempting to pass it straight through. If you intend a partial update, flatten it into dotted paths first (see the driver section below).

Creating Missing Parents

$set with a dotted path creates any missing intermediate documents:

db.users.updateOne(
  { _id: 3 },
  { $set: { "prefs.notifications.email": true, "address.city": "Helsinki" } },
);
// Linus now has prefs.notifications: { email: true } and address: { city: "Helsinki" }

That's convenient, but it can't create a path through a field that holds a non-document value. If address were the string "Helsinki", setting "address.city" would fail with a "Cannot create field 'city' in element" error.

Other Operators on Nested Paths

Every field operator accepts dotted paths:

db.users.updateOne(
  { _id: 2 },
  {
    $unset: { "prefs.notifications.sms": "" },
    $inc: { "stats.logins": 1 },
    $currentDate: { "stats.lastLogin": true },
    $rename: { "address.postcode": "address.zip" },
  },
);

$unset on a nested field removes just that key, leaving an empty subdocument behind if it was the last one. If an empty notifications: {} bothers you, unset the parent too, or use a pipeline update to remove it conditionally.

Replacing a Subdocument on Purpose

Sometimes replacing the whole subdocument is exactly right, for example when a user submits a full address form and every field is required. In that case $set: { address: {...} } is correct and clearer than setting three paths. The key is that it's a deliberate choice. A useful convention: replace when the input represents the complete value; use dotted paths when the input is a patch.

Projection with Nested Fields

Projection also understands dotted paths:

db.users.find({}, { name: 1, "address.city": 1, _id: 0 });
[
  { name: "Ada", address: { city: "Cambridge" } },
  { name: "Grace", address: { city: "Arlington" } },
  { name: "Linus", address: { city: "Helsinki" } },
];

The result keeps the nesting; it doesn't flatten address.city into a top-level key. You can also use the embedded form { address: { city: 1 } }, which is equivalent in modern versions. If you want a flat field, compute it with an expression:

db.users.find({}, { _id: 0, name: 1, city: "$address.city" });
// [ { name: "Ada", city: "Cambridge" }, ... ]

You can't include a parent and one of its children in the same projection ({ address: 1, "address.city": 1 }), because it causes a path collision error. See the guide to MongoDB projection for the full set of rules.

Indexing Nested Fields

Indexes work on dotted paths just like top-level fields:

db.users.createIndex({ "address.city": 1 });
db.users.createIndex({ "prefs.theme": 1, "address.city": 1 });

You can also index an entire subdocument (createIndex({ address: 1 })), but that index only helps whole-document equality queries, the brittle kind described above. It won't help { "address.city": "London" }. In practice, index the specific nested fields you filter and sort on.

If you have many optional nested fields and don't know in advance which ones will be queried, a wildcard index can cover them:

db.products.createIndex({ "attributes.$**": 1 });

This indexes every field under attributes. It's useful for user-defined attributes (size, colour, voltage), but it's larger than a targeted index and can't support every query shape a normal compound index can. Treat it as a tool for genuinely unpredictable fields, not a replacement for thinking about your queries.

Nested Fields in Aggregation

In aggregation expressions, you refer to a nested value with a $-prefixed path: "$address.city". Grouping users by city:

db.users.aggregate([
  { $match: { "address.city": { $exists: true } } },
  { $group: { _id: "$address.city", users: { $push: "$name" } } },
]);

A few stages are particularly handy with nested data:

db.users.aggregate([
  // promote a subdocument to the top level
  { $replaceWith: { $mergeObjects: [{ _id: "$_id" }, "$address"] } },
]);
// { _id: 1, street: "12 Analytical Way", city: "Cambridge", postcode: "N1 9GU" }

$getField and $setField let you read and write fields whose names contain dots or start with $, which ordinary paths can't handle. You'll rarely need them if you follow the naming advice below.

Working with Nested Data in Drivers

Drivers pass nested objects through naturally, so the overwrite bug follows you into application code. A small helper that flattens a patch object into dotted paths prevents it:

function toDotPaths(obj, prefix = "", out = {}) {
  for (const [key, value] of Object.entries(obj)) {
    const path = prefix ? `${prefix}.${key}` : key;
    const isPlainObject =
      value !== null &&
      typeof value === "object" &&
      !Array.isArray(value) &&
      !(value instanceof Date) &&
      value.constructor === Object;

    if (isPlainObject && Object.keys(value).length > 0) {
      toDotPaths(value, path, out);
    } else {
      out[path] = value;
    }
  }
  return out;
}

// PATCH /users/1  { address: { city: "Oxford" }, prefs: { theme: "light" } }
const patch = toDotPaths(req.body);
// { "address.city": "Oxford", "prefs.theme": "light" }

await users.updateOne({ _id: userId }, { $set: patch });

The value.constructor === Object check stops the helper from recursing into ObjectId, Decimal128, and other BSON types, which are objects in JavaScript but should be stored as values. In a real API, also whitelist which paths a client can set; flattening arbitrary input is a convenient route to letting users write fields they shouldn't.

In Python, the equivalent is straightforward:

def to_dot_paths(obj, prefix=""):
    out = {}
    for key, value in obj.items():
        path = f"{prefix}.{key}" if prefix else key
        if isinstance(value, dict) and value:
            out.update(to_dot_paths(value, path))
        else:
            out[path] = value
    return out


users.update_one({"_id": 1}, {"$set": to_dot_paths({"address": {"city": "Oxford"}})})

If you use Mongoose, note that assigning doc.address.city = "Oxford" and calling save() sends a dotted $set for you, because Mongoose tracks changes per path. Assigning doc.address = { city: "Oxford" } replaces the subdocument, just like raw $set would.

Naming Rules and Depth

Avoid dots and leading $ in field names. MongoDB permits them in recent versions, but query and update syntax treats dots as path separators, so a key like "v1.2" becomes awkward to address. Use a different separator (v1_2) or store such keys as values in an array of { k, v } pairs.

Don't use data as keys. A structure like { scores: { "2026-09-01": 12, "2026-09-02": 15 } } looks tidy, but you can't index or range-query those dates efficiently. Prefer scores: [ { day: ISODate(...), value: 12 } ].

Keep nesting purposeful. MongoDB supports up to 100 levels of nesting, but two or three levels usually cover real needs. Deep paths like "a.b.c.d.e" are hard to read, hard to index well, and a sign the structure may be modelling something better expressed as separate fields or documents.

Common Pitfalls

Setting a subdocument when you meant to patch it. $set: { prefs: { theme: "light" } } deletes every other preference. Use "prefs.theme" unless you truly intend replacement.

Querying with a partial embedded document. { address: { city: "London" } } only matches if address contains nothing else. Use { "address.city": "London" }.

Relying on field order. Whole-document equality depends on key order, which can vary between documents. Dotted conditions don't care.

Forgetting the quotes. Unquoted dotted keys are syntax errors in JavaScript, and some linters auto-format them in ways that hide the mistake. Always quote paths.

Indexing the parent instead of the child. An index on address doesn't help queries on address.city. Index the fields you actually filter on.

Conclusion

Nested documents let your data mirror your application's objects, and dot notation is how you work with them precisely. Use dotted paths for queries so partial matches work and field order stops mattering. Use dotted paths for updates so you change one field without erasing its siblings, and replace whole subdocuments only when the new value is genuinely complete. Index the specific nested fields you query, and keep structures shallow and keys free of dots.

Grep your code for $set calls whose values are objects, such as $set: { address: ... } or $set: { settings: ... }. For each one, decide whether it's meant to be a replacement or a patch, and convert the patches to dotted paths.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading