MongoDBOne-to-Many Relationships

One-to-Many Relationships

One-to-many is the most common relationship in any schema — one order has many line items, one author has many posts, one department has many employees. MongoDB gives you four distinct ways to model it, and picking the right one depends on cardinality and how the data is accessed.

The Four Patterns
  • Embedded array — children live inside the parent document as an array of sub-documents.

  • Array of references — parent stores an array of child IDs; children live in their own collection.

  • Parent reference (child-referencing-parent) — each child document stores the parent's ID; no array on the parent at all.

  • Two-way reference — parent stores child IDs AND each child stores the parent ID (rare, more write overhead, used when both directions need to be indexed independently).

Pattern 1: Embedded Array

Best when the "many" side is small and bounded, and is always read alongside the parent. The classic example: an order and its line items.

Order with embedded line items

JS
{
  _id: ObjectId("order1"),
  customerId: ObjectId("cust1"),
  status: "shipped",
  createdAt: ISODate("2026-01-15"),
  items: [
    { productId: ObjectId("p1"), name: "Wireless Mouse", price: 24.99, qty: 2 },
    { productId: ObjectId("p2"), name: "USB-C Cable",   price: 9.99,  qty: 1 }
  ],
  total: 59.97
}

// Reading the whole order — one query, no joins
db.orders.findOne({ _id: ObjectId("order1") })

// Adding a line item is a single atomic update
db.orders.updateOne(
  { _id: ObjectId("order1") },
  { $push: { items: { productId: ObjectId("p3"), name: "Keyboard", price: 49.99, qty: 1 } },
    $inc: { total: 49.99 } }
)
Tip
Embedding line items also gets you free atomicity: adding an item and updating the order total happen in a single document write, with no transaction required.
Pattern 2: Array of References

Use this when children are shared, queried independently, or too large/unbounded to embed, but you still want a fast way to list "this parent's children" without a full collection scan.

Author with an array of post IDs

JS
// authors collection
{ _id: ObjectId("author1"), name: "Priya Shah", postIds: [ObjectId("post1"), ObjectId("post2")] }

// posts collection — the actual content lives here
{ _id: ObjectId("post1"), title: "Intro to Indexes", authorId: ObjectId("author1") }
{ _id: ObjectId("post2"), title: "Aggregation Basics", authorId: ObjectId("author1") }

// Fetch an author's posts via $lookup
db.authors.aggregate([
  { $match: { _id: ObjectId("author1") } },
  { $lookup: {
      from: "posts",
      localField: "postIds",
      foreignField: "_id",
      as: "posts"
  } }
])
Warning
An array of references still grows with the parent document. If an author could eventually have 50,000 posts, this array becomes the same unbounded-growth problem as embedding — prefer the parent reference pattern instead.
Pattern 3: Parent Reference (the scalable default)

For anything that can grow without bound, put the reference on the "many" side instead. The parent document never changes size as children are added — this is the pattern used for comments, order history, sensor readings, log entries, and virtually every "one-to-squillions" relationship.

Order and order-items, worked example

JS
// orders collection stays small and fixed-size
{ _id: ObjectId("order1"), customerId: ObjectId("cust1"), status: "shipped", total: 59.97 }

// order_items collection — one document per line item, referencing the order
{ _id: ObjectId("i1"), orderId: ObjectId("order1"), productId: ObjectId("p1"), name: "Wireless Mouse", price: 24.99, qty: 2 }
{ _id: ObjectId("i2"), orderId: ObjectId("order1"), productId: ObjectId("p2"), name: "USB-C Cable",   price: 9.99,  qty: 1 }

// Index the foreign key
db.order_items.createIndex({ orderId: 1 })

// Fetch all items for an order — fast, indexed
db.order_items.find({ orderId: ObjectId("order1") })

// Fetch order + items together via $lookup when a joined view is needed
db.orders.aggregate([
  { $match: { _id: ObjectId("order1") } },
  { $lookup: { from: "order_items", localField: "_id", foreignField: "orderId", as: "items" } }
])
Note
This is functionally close to a normalized SQL foreign key — the difference is you choose it deliberately for scalability, not by default. For most order systems with a bounded number of items per order, embedding (Pattern 1) is still simpler and just as valid.
Choosing by Cardinality and Access Pattern

Situation

Recommended Pattern

A handful of items, always shown with parent

Embedded array

Dozens to low thousands, mostly read with parent

Embedded array (watch document size)

Children queried/paginated independently of parent

Parent reference

Children shared across multiple parents

Array of references or parent reference

Unbounded growth (logs, events, readings)

Parent reference (child holds the FK)

Need fast "list all children of X" without $lookup

Parent reference + index on the FK field

Combining Patterns: Recent Items Embedded, Full History Referenced

JS
// Common hybrid: embed the last few items for a fast dashboard view,
// keep the full history in a referenced collection for deep queries
{
  _id: ObjectId("cust1"),
  name: "Acme Corp",
  recentOrders: [   // last 5, embedded — fast to render on the customer page
    { orderId: ObjectId("order9"), total: 120.00, date: ISODate("2026-06-01") }
  ]
}
// Full order history lives in the orders collection, queried by customerId
// when the user clicks "view all orders"
Summary
  • Embedded array: simplest, atomic, best for small and bounded child sets read with the parent.

  • Array of references: good middle ground, but still grows with the parent — watch cardinality.

  • Parent reference: the scalable default for anything unbounded; index the foreign key field.

  • Hybrid (recent embedded + full referenced) balances fast reads with unlimited history.