One-to-Many Relationships

A one-to-many relationship is any case where one document logically “owns” or relates to several others — a customer with many addresses, an author with many blog posts, a sensor with millions of readings. Unlike SQL, where every one-to-many relationship is modeled the same way with a foreign key, MongoDB gives you a choice: embed the many side directly inside the one document, or reference it in a separate collection. Picking correctly is the single most important schema design decision you’ll make, because it directly affects query performance, document size limits, and how easy the data is to update.

Overview / How it works

In SQL, a one-to-many relationship (customer → orders) is always two tables joined by a foreign key, because rows have a fixed shape and joins are cheap and built-in. MongoDB documents are BSON — a binary superset of JSON that also supports types like ObjectId, Date, Decimal128, and binary data — and a document can contain nested objects and arrays. That means the “many” side of a relationship can physically live inside the “one” document as an array, not just as separate rows linked by an id.

MongoDB’s own engineering team describes three flavors of one-to-many, and the right pattern depends on which one you’re facing:

  • One-to-few (a customer has 2-3 addresses, a product has a handful of variants): embed the array directly in the parent document. It’s small, bounded, and almost always read together with the parent.
  • One-to-many (an author has dozens or hundreds of posts, a category has hundreds of products): reference the children in their own collection, storing the parent’s _id on each child document, and query/join with $lookup when you need both together.
  • One-to-squillions (a sensor produces millions of readings, a server produces millions of log lines): always reference, index the parent reference field, and consider a hybrid “subset” or “bucket” pattern so the parent document stays small while the full history lives elsewhere.

Embedding is fast to read (one document, one disk fetch, no join) and keeps related data atomic within a single-document write. Its downside is the 16MB BSON document size limit and the fact that arrays that keep growing cause the document to be rewritten and potentially moved on disk as it outgrows its allocated space, which slows writes over time. Referencing avoids unbounded growth and lets many children reference the same parent efficiently, but requires an extra query or an aggregation $lookup to reassemble the data, and that lookup is only fast if the foreign key field is indexed.

Syntax

There’s no dedicated “relationship” syntax in MongoDB — you express the relationship through document shape and, optionally, an aggregation join. The two shapes:

// Embedding: the "many" side lives inside the parent as an array
db.customers.insertOne({
  name: "...",
  addresses: [ { type: "...", street: "..." }, { type: "...", street: "..." } ]
});

// Referencing: the child stores the parent's _id
db.posts.insertOne({ title: "...", authorId: ObjectId("...") });

// Reassembling referenced data with $lookup
db.authors.aggregate([
  { $match: { _id: ObjectId("...") } },
  { $lookup: { from: "posts", localField: "_id", foreignField: "authorId", as: "posts" } }
]);
$lookup field Meaning
from the child (foreign) collection to join against
localField field on the current (parent) document, usually _id
foreignField field on the child document that stores the parent reference
as name of the new array field holding matched child documents

Examples

Example 1: One-to-few — embedding addresses in a customer

db.customers.insertOne({
  name: "Priya Sharma",
  email: "priya@example.com",
  addresses: [
    { type: "home", street: "12 MG Road", city: "Pune", zip: "411001" },
    { type: "work", street: "45 Business Park", city: "Pune", zip: "411006" }
  ]
});

db.customers.find({ "addresses.city": "Pune" }, { name: 1, addresses: 1 });
Output:
{ acknowledged: true, insertedId: ObjectId("64f1a1a1a1a1a1a1a1a1a1a1") }

[
  {
    _id: ObjectId("64f1a1a1a1a1a1a1a1a1a1a1"),
    name: "Priya Sharma",
    addresses: [
      { type: "home", street: "12 MG Road", city: "Pune", zip: "411001" },
      { type: "work", street: "45 Business Park", city: "Pune", zip: "411006" }
    ]
  }
]

Because there are only a couple of addresses, embedding is ideal: reading a customer always brings back their addresses for free, in one document fetch, with no join required. The dotted path "addresses.city" lets you query into array elements directly.

Example 2: One-to-many — referencing posts to an author

db.authors.insertOne({
  _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),
  name: "Meera Nair",
  bio: "Backend engineer and technical writer"
});

db.posts.insertMany([
  { title: "Indexing Deep Dive", authorId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"), tags: ["mongodb", "index"], publishedAt: new Date("2026-01-10") },
  { title: "Aggregation Pipelines Explained", authorId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"), tags: ["mongodb", "aggregation"], publishedAt: new Date("2026-02-02") }
]);

db.posts.createIndex({ authorId: 1 });

db.authors.aggregate([
  { $match: { _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") } },
  { $lookup: { from: "posts", localField: "_id", foreignField: "authorId", as: "posts" } }
]);
Output:
[
  {
    _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),
    name: "Meera Nair",
    bio: "Backend engineer and technical writer",
    posts: [
      { _id: ObjectId("..."), title: "Indexing Deep Dive", authorId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"), tags: ["mongodb","index"], publishedAt: ISODate("2026-01-10T00:00:00.000Z") },
      { _id: ObjectId("..."), title: "Aggregation Pipelines Explained", authorId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"), tags: ["mongodb","aggregation"], publishedAt: ISODate("2026-02-02T00:00:00.000Z") }
    ]
  }
]

Here the author can have an unbounded number of posts over time, so each post references its author by _id instead of the author embedding every post. The index on authorId makes both the plain find and the $lookup efficient — without it, MongoDB would have to scan every document in posts for each author it joins.

Example 3: One-to-squillions — a hybrid subset pattern

db.devices.insertOne({ _id: "sensor-42", location: "Warehouse A", recentReadings: [] });

db.readings.insertMany([
  { deviceId: "sensor-42", value: 21.4, recordedAt: new Date("2026-08-01T08:00:00Z") },
  { deviceId: "sensor-42", value: 21.9, recordedAt: new Date("2026-08-01T08:05:00Z") }
]);

db.readings.createIndex({ deviceId: 1, recordedAt: -1 });

db.devices.updateOne(
  { _id: "sensor-42" },
  { $push: { recentReadings: { $each: [{ value: 21.9, recordedAt: new Date("2026-08-01T08:05:00Z") }], $slice: -10 } } }
);
Output:
{ acknowledged: true, insertedId: 'sensor-42' }
{ acknowledged: true, insertedIds: { '0': ObjectId("..."), '1': ObjectId("...") } }
{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }

A sensor can produce millions of readings, far too many to ever embed. The full history lives in its own readings collection, indexed on deviceId plus a sort key so recent-first queries use the index. The device document keeps only a capped recentReadings subset (via $slice: -10) so a dashboard can read the latest values with a single fast fetch, without touching the full history collection.

How it works step by step

When you embed, a write to the parent (like pushing a new address) is a single-document update, which MongoDB guarantees is atomic — no partial writes are visible to other readers. But if the embedded array keeps growing past the space WiredTiger originally allocated for the document, the storage engine has to relocate the whole document on disk, which is more expensive than an in-place update; this is why unbounded arrays are dangerous even though embedding itself is fine.

When you reference and later reassemble with $lookup, the aggregation engine effectively runs, for each parent document (or batch of parents), an index lookup against the child collection’s foreignField — this is why that field needs an index. Without one, the query planner has no choice but to perform a full collection scan (COLLSCAN) of the child collection for every parent, which you can confirm by running explain("executionStats") on the aggregation and checking whether the lookup stage reports an IXSCAN. A plain find({ authorId: ... }) on the child collection goes through the same query planner logic: with an index on authorId, MongoDB seeks directly to the matching entries; without one, it scans every document in the collection to check the field.

Common Mistakes

Mistake 1: embedding an unbounded child array. Storing every order a customer has ever placed inside the customer document seems convenient at first:

// Wrong: unbounded embedding — this array has no upper limit
db.customers.updateOne(
  { _id: customerId },
  { $push: { orders: newOrder } }
);

Over years, a loyal customer’s document can approach the 16MB limit, and every push gets slower as the document grows and gets relocated on disk. Reference orders in their own collection instead, keyed by customerId, and index that field:

// Corrected: reference orders, keep the customer document small
db.orders.insertOne({ customerId: customerId, items: [...], total: 149.99, placedAt: new Date() });
db.orders.createIndex({ customerId: 1, placedAt: -1 });

Mistake 2: comparing an ObjectId reference to a plain string. A very common bug when the parent id comes from a URL parameter or a form field:

// Wrong: req.params.authorId is a string like "64f1a2b3c4d5e6f7a8b9c0d1",
// but authorId in the documents is stored as an ObjectId — this matches nothing
db.posts.find({ authorId: req.params.authorId });
// Corrected: convert the string to an ObjectId before querying
db.posts.find({ authorId: new ObjectId(req.params.authorId) });

Mistake 3: joining on an unindexed foreign key. Running $lookup against a large posts collection without an index on authorId silently works but performs a full collection scan per parent, which gets dramatically slower as the collection grows — always create the index before you rely on the join in production.

Best Practices

  • Embed when the child data is small, bounded, and almost always read alongside the parent (addresses, line items on an order).
  • Reference when the child data is large, grows without bound, or needs to be queried independently of its parent (posts, orders, readings).
  • Always create an index on the field that stores the parent reference (or on localField/foreignField used in $lookup) before relying on it in production.
  • Use $slice with $push to cap an embedded array’s size when you want a bounded “recent items” subset alongside a full referenced history.
  • Run explain("executionStats") on relationship queries to confirm you’re getting IXSCAN, not COLLSCAN.
  • Convert string ids to ObjectId with new ObjectId(idString) before querying — never compare an ObjectId field to a raw string.
  • Favor referencing over embedding as soon as “how many children could this have?” doesn’t have a small, fixed answer.

Practice Exercises

  • Model a library where each book document can have multiple reviews. Decide whether to embed or reference, and justify your choice given that a popular book could receive thousands of reviews.
  • Given categories and products collections where each product references its category by categoryId, write an aggregation that returns each category with its products embedded, and make sure the join uses an index (hint: run explain on it).
  • You have a teams collection where each team embeds a members array. A team can never exceed 50 members. Should you keep embedding, or switch to referencing? Explain your reasoning using the one-to-few vs one-to-many distinction.

Summary

  • One-to-many relationships in MongoDB can be modeled by embedding the children inside the parent, or by referencing the parent’s _id from each child document.
  • Use embedding for “one-to-few” relationships that are small, bounded, and read together with the parent.
  • Use referencing for “one-to-many” and “one-to-squillions” relationships, indexing the reference field and reassembling with $lookup when needed.
  • A hybrid subset/bucket pattern lets you embed a small, capped preview of children while the full history lives in a referenced collection.
  • Unbounded embedded arrays risk hitting the 16MB document limit and slow down writes as documents grow and get relocated on disk.
  • Always index the foreign key field used in a reference relationship, and always convert string ids to ObjectId before querying.