🔗 Relationships & Joins in MongoDB

MongoDB doesn't have foreign keys or native JOIN syntax like relational databases, but it still needs ways to represent connections between data — a customer and their orders, a post and its comments, an employee and their manager. This tutorial covers every relationship style available, from embedding to $graphLookup, and when to reach for each one.

Information

Examples use the official MongoDB Node.js Driver and assume collections named customers, orders, and employees.

📖 1. Introduction

Relationships in MongoDB come down to a single fundamental choice: should related data live inside the same document, or in a separate document connected by a reference? Every technique in this tutorial is a variation on that theme.

Relationship Styles
Embedded (one document)
Referenced (linked documents)

🧠 2. Understanding Relationships

Choosing a relationship style depends on three questions: how often the related data is accessed together, how large the related data grows, and whether it needs to be queried independently.

Tip

There's no single "correct" way to model a relationship in MongoDB — the right choice depends entirely on your application's access patterns.

đŸ“Ļ 3. Embedded Relationships

Embedding nests related data directly inside the parent document, allowing it to be fetched in a single read with no join required.

An embedded relationship

{
  _id: ObjectId("..."),
  name: "Alice",
  billingAddress: {
    street: "12 MG Road",
    city: "Chennai",
    zip: "600001"
  }
}

Tip

Embedding is ideal when related data is small, bounded, and always needed alongside its parent.

🔗 4. Referenced Relationships

Referencing stores a related document's _id instead of its full contents, requiring a separate query or $lookup to resolve it.

A referenced relationship

// orders collection
{
  _id: ObjectId("o1"),
  customerId: ObjectId("c1"),
  amount: 59.99
}

Tip

Referencing is ideal when related data is large, changes independently, or is shared across many parent documents.

1ī¸âƒŖâ†”ī¸1ī¸âƒŖ 5. One-to-One Relationships

Best modeled by embedding in most cases, unless the related data is large or infrequently accessed — for example, a rarely viewed user preferences document.

Referencing a rarely accessed one-to-one relationship

// Referenced alternative for large or rarely accessed data
// users collection
{ _id: ObjectId("u1"), name: "Alice" }

// user_preferences collection
{ _id: ObjectId("p1"), userId: ObjectId("u1"), theme: "dark", notifications: {} }

1ī¸âƒŖâ†”ī¸đŸ”ĸ 6. One-to-Many Relationships

The classic MongoDB modeling decision. A blog post with a handful of tags embeds well; a customer with thousands of orders does not.

Embedding vs. referencing a one-to-many relationship

// Embedded: small, bounded child list
{ title: "MongoDB Basics", tags: ["database", "nosql", "tutorial"] }

// Referenced: large, unbounded child list
// orders collection, referencing customer
{ _id: ObjectId("o1"), customerId: ObjectId("c1"), amount: 59.99 }

đŸ”ĸâ†”ī¸đŸ”ĸ 7. Many-to-Many Relationships

Typically modeled with arrays of references on one or both sides, depending on which direction is queried more often.

Many-to-many via arrays of references

// students collection
{ _id: ObjectId("s1"), name: "Alice", courseIds: [ObjectId("c1"), ObjectId("c2")] }

// courses collection
{ _id: ObjectId("c1"), title: "Intro to MongoDB", studentIds: [ObjectId("s1")] }

Caution

Keeping both sides of a many-to-many relationship in sync requires extra write logic — consider whether you truly need bidirectional references.

â™ģī¸ 8. Self-Referencing Relationships

A document can reference another document in the same collection, commonly used for hierarchies like categories or organizational charts.

A self-referencing category hierarchy

// categories collection
{ _id: ObjectId("cat1"), name: "Electronics", parentId: null }
{ _id: ObjectId("cat2"), name: "Laptops", parentId: ObjectId("cat1") }

đŸ‘Ē 9. Parent-Child Relationships

A common self-referencing pattern where each document stores a pointer to its parent, forming a tree structure such as an employee reporting hierarchy.

Modeling a management hierarchy

// employees collection
{ _id: ObjectId("e1"), name: "Priya", title: "CTO", managerId: null }
{ _id: ObjectId("e2"), name: "Arjun", title: "Engineer", managerId: ObjectId("e1") }

📑 10. Data Duplication

Referenced relationships sometimes benefit from duplicating a small, rarely changing piece of related data to avoid an extra lookup on every read.

Duplicating a field to avoid a join

{
  _id: ObjectId("o1"),
  customerId: ObjectId("c1"),
  customerName: "Alice", // duplicated for fast display without a $lookup
  amount: 59.99
}

Warning

Duplicated data must be kept in sync manually — if a customer changes their name, every order referencing it needs updating too.

🔖 11. Document Linking

The simplest linking mechanism is storing the related document's _id in a field, then querying for it directly when needed.

Resolving a manual reference with a second query

const customer = await customers.findOne({ _id: order.customerId });

âœī¸ 12. Manual References

A manual reference is simply an _id value stored in another document — no special BSON type required. This is the recommended approach for the vast majority of relationships.

A manual reference

{
  _id: ObjectId("o1"),
  customerId: ObjectId("c1") // plain manual reference
}

Note

Manual references are simple, index-friendly, and work naturally with $lookup — this is the standard approach in modern MongoDB applications.

📎 13. DBRefs

DBRefs are a legacy convention that embeds the collection name alongside the referenced _id, mainly useful when a field might reference documents from multiple collections.

A DBRef pointing to another collection

{
  _id: ObjectId("comment1"),
  attachedTo: { $ref: "posts", $id: ObjectId("p1") }
}

Caution

DBRefs are rarely necessary in modern applications — a plain manual reference plus an application-level type field is usually simpler and just as effective.

🔗 14. $lookup

The $lookup aggregation stage performs a left outer join against another collection in the same database, resolving references into embedded arrays.

Joining orders with their customer

await orders.aggregate([
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
    }
  },
  { $unwind: "$customer" }
]).toArray();

Tip

Use the pipeline form of $lookup when you need to filter or reshape the joined documents rather than including them in full.

đŸŒŗ 15. $graphLookup

Performs a recursive search through a collection, following a chain of references — perfect for hierarchies like organizational charts or category trees.

Finding an employee's full management chain

await employees.aggregate([
  { $match: { name: "Arjun" } },
  {
    $graphLookup: {
      from: "employees",
      startWith: "$managerId",
      connectFromField: "managerId",
      connectToField: "_id",
      as: "managementChain"
    }
  }
]).toArray();

Example

This returns every manager above Arjun in the hierarchy, no matter how many levels deep, in a single query.

🧭 16. Join Strategies

StrategyBest for
EmbeddingSmall, bounded, always-together data
Manual reference + $lookupLarger or independently queried data
$graphLookupRecursive hierarchies of unknown depth
Application-level joinsCross-database or cross-service data

đŸĒ† 17. Nested Relationships

Relationships can be nested several levels deep — an order references a customer, who references a loyalty tier, and so on. Each level adds a $lookup stage.

Chaining multiple $lookup stages

await orders.aggregate([
  { $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
  { $unwind: "$customer" },
  { $lookup: { from: "loyaltyTiers", localField: "customer.tierId", foreignField: "_id", as: "customer.tier" } }
]).toArray();

Caution

Each additional $lookup level adds overhead — consider denormalizing frequently needed nested fields instead of joining several layers deep.

🔁 18. Recursive Relationships

Use $graphLookup's maxDepth option to bound how far a recursive relationship traversal goes, preventing runaway queries on deep or cyclic hierarchies.

Limiting recursion depth

{
  $graphLookup: {
    from: "categories",
    startWith: "$parentId",
    connectFromField: "parentId",
    connectToField: "_id",
    as: "ancestors",
    maxDepth: 5
  }
}

⚡ 19. Relationship Performance

  • Index the localField and foreignField used in every $lookup.
  • Prefer embedding over $lookup when the joined data is small and read-heavy.
  • Use the pipeline form of $lookup with an early $match to reduce the joined dataset.
  • Bound $graphLookup traversals with maxDepth to avoid unpredictable query times.

🔐 20. Data Consistency

Since MongoDB has no foreign key enforcement, keeping references valid — and duplicated fields in sync — is the application's responsibility.

Important

Use multi-document transactions when an operation must update several related documents atomically, such as duplicating a name change across orders.

đŸ›ī¸ 21. Relationship Design Patterns

Real applications typically combine several relationship styles. An order might embed its line items, reference its customer, and denormalize the customer's name for display.

Combining relationship styles in one document

{
  _id: ObjectId("o1"),
  customerId: ObjectId("c1"),      // reference
  customerName: "Alice",           // denormalized
  items: [                         // embedded
    { productId: ObjectId("p1"), name: "Mouse", qty: 2 }
  ]
}

🌟 22. Best Practices

  • Default to embedding for small, bounded, always-together data.
  • Use manual references plus $lookup for larger or independently queried data.
  • Reserve $graphLookup for genuinely recursive hierarchies, not simple parent-child links.
  • Index every field used as a join key in $lookup or $graphLookup.
  • Use transactions when duplicated or referenced data must change atomically together.

âš ī¸ 23. Common Mistakes

  1. Modeling every relationship with $lookup, recreating relational-style joins unnecessarily.
  2. Embedding a one-to-many relationship that grows unbounded, risking the 16MB document limit.
  3. Forgetting to index localField/foreignField pairs used in frequent joins.
  4. Using $graphLookup without a maxDepth on data that may contain cycles.
  5. Letting denormalized duplicate fields drift out of sync with their source of truth.

Best Practice

When unsure whether to embed or reference, ask: "Could this array grow without a natural limit?" If yes, reference it.

❓ 24. Frequently Asked Questions

No. MongoDB does not enforce referential integrity — the application is responsible for keeping references valid.

Use $graphLookup when the relationship is recursive, like a category tree or reporting chain, and the depth isn't known in advance. A single-level relationship only needs $lookup.

Generally, no. Manual references are simpler and better supported by modern tooling; DBRefs are mostly a legacy convention.

📌 25. Summary

>>"The best relationship model is the one that matches how your application actually reads its data — not the one that looks most normalized on paper."

Summary

Relationships in MongoDB are a spectrum between embedding and referencing, not a fixed set of rules. Continue with MongoDB's data modeling guide and transactions to deepen your understanding of consistency across related documents.