Embedded Documents and Arrays

One of the biggest differences between MongoDB and a relational database is that a single document can contain other documents and arrays nested inside it. Instead of splitting a customer and their address into two tables joined by a foreign key, you can store the address as an object right inside the customer document. This is called embedding, and it’s the feature that lets MongoDB model rich, real-world objects — a blog post with its comments, an order with its line items — as a single unit that can be read or written in one operation.

Overview / How it works

Every MongoDB document is stored as BSON (Binary JSON), and BSON values aren’t limited to strings, numbers, booleans, and nulls the way plain JSON is. A BSON field’s value can itself be an embedded document (a nested object with its own fields) or an array (an ordered list of values, which can themselves be scalars, documents, or even other arrays). This nesting can go arbitrarily deep, though in practice you rarely want more than two or three levels — deeply nested structures become hard to query and update.

Embedding matters because it lets you model a one-to-few or one-to-many relationship without a join. In SQL you’d store orders and order_items as separate tables and join them at query time. In MongoDB, if the line items always belong to exactly one order and are always read together with it, you can embed them directly as an array field on the order document. Reading the whole order — header and items — is then a single document fetch, no join required.

The tradeoff is that embedded data lives and dies with its parent document, and it counts toward that document’s size. Every MongoDB document has a hard 16MB size limit, and large or unbounded arrays are the most common way lessons and real applications hit it. The general rule of thumb: embed when the nested data is naturally bounded, is mostly read together with the parent, and doesn’t need to be queried or updated independently very often. Reference (store an _id and look the related data up separately, often with $lookup) when the data is large, shared across many parents, or grows without a natural limit.

Embed when… Reference when…
Data is always read together with the parent (an order and its line items) Data is large or grows unboundedly (a product’s full review history)
The child data belongs to exactly one parent The child data is shared by many parents (a product referenced by thousands of orders)
The array has a natural cap (a handful of addresses per user) You need to query or update the child data independently of the parent

Syntax

There’s no special syntax to declare an embedded document or array — you simply write a JavaScript object or array literal as the value of a field, exactly as you would in plain JSON:

db.collectionName.insertOne({
  field1: value1,
  embeddedDoc: {
    subField1: value,
    subField2: value
  },
  embeddedArray: [
    { itemField1: value, itemField2: value },
    { itemField1: value, itemField2: value }
  ]
});
  • embeddedDoc — a field whose value is itself a document (an object literal); its subfields are accessed with dot notation like "embeddedDoc.subField1".
  • embeddedArray — a field whose value is an array; elements can be scalars, documents, or nested arrays, and are accessed by position ("embeddedArray.0") or matched by content with $elemMatch.

Examples

Example 1: An embedded document (one-to-one)

db.users.insertOne({
  name: "Priya Sharma",
  email: "priya@example.com",
  address: {
    street: "221B Baker Street",
    city: "Mumbai",
    zip: "400001",
    country: "India"
  }
});
// Output:
{
  acknowledged: true,
  insertedId: ObjectId("66b1f2a4c1d2e3f4a5b6c7d8")
}

The address field holds a full document, not just a reference. To query on a nested field you use dot notation, wrapped in quotes because of the dot:

db.users.findOne({ "address.city": "Mumbai" });
// Output:
{
  _id: ObjectId("66b1f2a4c1d2e3f4a5b6c7d8"),
  name: "Priya Sharma",
  email: "priya@example.com",
  address: {
    street: "221B Baker Street",
    city: "Mumbai",
    zip: "400001",
    country: "India"
  }
}

Example 2: An array of embedded documents (one-to-many)

db.orders.insertOne({
  customer: "Priya Sharma",
  status: "processing",
  items: [
    { product: "Wireless Mouse", price: 19.99, qty: 2 },
    { product: "USB-C Hub", price: 34.50, qty: 1 }
  ],
  createdAt: new Date()
});

Now suppose you want every order that contains a USB-C Hub with at least 1 unit. Using two separate dot-notation conditions would be wrong here (see Common Mistakes below) — the correct tool is $elemMatch, which requires a single array element to satisfy all the listed conditions at once:

db.orders.find({
  items: {
    $elemMatch: { product: "USB-C Hub", qty: { $gte: 1 } }
  }
});
// Output:
[
  {
    _id: ObjectId("66b1f31bc1d2e3f4a5b6c7d9"),
    customer: "Priya Sharma",
    status: "processing",
    items: [
      { product: "Wireless Mouse", price: 19.99, qty: 2 },
      { product: "USB-C Hub", price: 34.50, qty: 1 }
    ],
    createdAt: ISODate("2026-08-03T10:15:00.000Z")
  }
]

Example 3: Updating array elements in place

To change one field on an array element that matches a query condition, use the positional operator $, which stands in for "the index of the first array element that matched the query filter":

db.orders.updateOne(
  { customer: "Priya Sharma", "items.product": "Wireless Mouse" },
  { $set: { "items.$.price": 17.99 } }
);
// Output:
{
  acknowledged: true,
  matchedCount: 1,
  modifiedCount: 1
}

To add a brand-new item to the array instead of modifying an existing one, use $push:

db.orders.updateOne(
  { customer: "Priya Sharma" },
  { $push: { items: { product: "Keyboard", price: 45.00, qty: 1 } } }
);

Each call touched the document as a whole: MongoDB read the document, applied the change to the embedded array, and rewrote it — there was no join and no second collection involved.

How it works step by step

When you run a query with a dot-notation path like "address.city", the query planner treats it as a path into the BSON document tree: it walks into the address subdocument and compares its city field, the same as it would compare a top-level field. If you’ve created an index on "address.city", this lookup can use an IXSCAN instead of a full COLLSCAN, exactly like indexing a top-level field.

For arrays, MongoDB automatically creates what’s called a multikey index when you index an array field: internally, it indexes one entry per array element, so a query like { "items.product": "Keyboard" } can jump straight to documents containing that value without scanning every element of every document. $elemMatch queries can also use a multikey index, but the engine still has to fetch the document to confirm all conditions matched the same element, since the index alone doesn’t preserve that pairing.

On a write, $push and $set against an embedded array modify the document in place and rewrite it to storage as a whole (or in part, via the WiredTiger storage engine’s internal diffing) — the entire document, including all its embedded content, is what gets locked and updated atomically. That’s why single-document writes, even ones touching deeply nested arrays, are always atomic in MongoDB without needing a multi-document transaction.

Common Mistakes

Mistake 1: Matching array elements independently instead of together

It’s tempting to filter an array field with two separate dot-notation conditions, but each condition is evaluated against the array as a whole, not against one specific element:

// WRONG: matches ANY order where SOME item is a Mouse
// AND (possibly a different item) has qty 5
db.orders.find({
  "items.product": "Wireless Mouse",
  "items.qty": 5
});

This can return an order where item 0 is { product: "Wireless Mouse", qty: 2 } and item 1 is { product: "Keyboard", qty: 5 } — a false match, because neither condition requires the same array element to satisfy both. The fix is $elemMatch, which ties both conditions to one element:

// CORRECT: both conditions must match the SAME item
db.orders.find({
  items: { $elemMatch: { product: "Wireless Mouse", qty: 5 } }
});

Mistake 2: Letting an embedded array grow without bound

Embedding is great for a bounded list, but a design like "push every activity event onto the user’s document forever" will eventually hit the 16MB document limit and, long before that, will make every read and write to that user slower as the document grows:

// WRONG: unbounded growth, no cap
db.users.updateOne(
  { _id: userId },
  { $push: { activityLog: newEvent } }
);

If the log genuinely needs to grow without limit, store it in its own activityLog collection referencing userId, and query it separately. If you only need the recent history embedded for convenience, cap it with $slice so the array never exceeds a fixed length:

// CORRECT: keep only the most recent 50 events
db.users.updateOne(
  { _id: userId },
  {
    $push: {
      activityLog: {
        $each: [newEvent],
        $slice: -50
      }
    }
  }
);

Best Practices

  • Default to embedding for data that is bounded in size and almost always read together with its parent (an address, a shipping label, a small settings object).
  • Switch to referencing once an array could realistically grow past a few hundred elements, or once the nested data needs to be queried, updated, or shared independently of its parent.
  • Use $elemMatch whenever a query needs multiple conditions to hold on the same array element — plain dot-notation conditions match across elements independently.
  • Create an index on the fields you filter by inside embedded documents or arrays (e.g. { "items.product": 1 }) and confirm it’s used with .explain("executionStats").
  • Use the positional operator $ to update the first matched array element, and arrayFilters with $[identifier] when you need to update several matching elements in one call.
  • Cap growable arrays with $slice in the same $push call that adds to them, rather than trimming them later.
  • Keep nesting shallow — two or three levels deep at most. Deeply nested structures are hard to query, hard to index well, and hard to reason about.

Practice Exercises

  • Create a db.posts collection where each blog post document embeds a comments array; each comment should have author, text, and likes fields. Insert one post with two comments.
  • Write a query against your posts collection that finds posts containing a comment where author is a specific name and likes is greater than 10 — think carefully about whether you need $elemMatch and why.
  • Write an update that pushes a new comment onto a post’s comments array while keeping only the most recent 20 comments, using $each and $slice.

Summary

  • BSON fields can hold embedded documents (nested objects) and arrays (ordered lists), which can themselves contain further documents or arrays.
  • Embedding avoids joins and keeps related data in one atomic document; referencing keeps large, shared, or unbounded data separate.
  • Dot notation ("field.subfield") reaches into embedded documents and array elements for both queries and updates.
  • $elemMatch is required whenever multiple conditions must all match the same array element, not different elements independently.
  • $push, the positional $ operator, and arrayFilters with $[identifier] are the core tools for adding to and updating array elements.
  • Unbounded arrays risk hitting the 16MB document size limit and degrade performance well before that — cap them with $slice or move to a referenced collection.