MongoDB $graphLookup Stage

Beginner
⏱️ 12 min read
📚 Updated: Jul 2026
🎯 5 Examples
Aggregation

What You’ll Learn

The $graphLookup stage recursively walks relationships in a collection—returning every connected document in one array. Think org charts, nested comments, product categories, or bill-of-materials trees without writing application-level loops.

01

Recursive join

Beyond one level.

02

startWith

Where to begin.

03

connect fields

Link parent ↔ child.

04

maxDepth

Limit hop count.

05

depthField

Track tree level.

06

vs $lookup

When to use each.

Definition and Usage

$graphLookup is an aggregation stage that performs a recursive search on a collection. Starting from a value you specify (startWith), MongoDB finds documents where connectToField matches that value, then uses each match’s connectFromField to find the next level—and repeats until the graph is exhausted or maxDepth is reached.

💡
Beginner tip

Picture an org chart: each employee has reportsTo pointing to their manager’s _id. Set startWith: "$_id", connectFromField: "_id", and connectToField: "reportsTo" to fetch every subordinate—direct reports, their reports, and so on.

Use $graphLookup when relationships span multiple levels and you want them in a single aggregation query. For a flat one-to-many join (orders → line items), $lookup is simpler and faster.

📝 Syntax

$graphLookup accepts a document describing the graph traversal:

mongosh
{
  $graphLookup: {
    from: <string>,
    startWith: <expression>,
    connectFromField: <string>,
    connectToField: <string>,
    as: <string>,
    maxDepth: <number>,
    depthField: <string>,
    restrictSearchWithMatch: <document>
  }
}

Syntax Rules

  • from — required; collection to search (often the same collection).
  • startWith — required; expression evaluated on the input document (e.g. "$_id" or "$parentId").
  • connectFromField — required; field in from whose value becomes the next search key.
  • connectToField — required; field in from that must equal the current search value.
  • as — required; name of the array field added to each input document.
  • maxDepth — optional; zero-based max recursion depth (omit for unlimited).
  • restrictSearchWithMatch — optional; filter documents in from during the search.
mongosh
db.employees.aggregate([
  { $match: { name: "Alice Chen" } },
  {
    $graphLookup: {
      from: "employees",
      startWith: "$_id",
      connectFromField: "_id",
      connectToField: "reportsTo",
      as: "allReports"
    }
  }
])

⚡ Quick Reference

QuestionAnswer
What it doesRecursively finds connected documents in a collection
Output shapeArray field (as) on each input document
Typical useTrees, org charts, threads, BOM, categories
vs $lookup$lookup = one hop; $graphLookup = many hops
Depth controlmaxDepth: 0 = direct links only
Index tipIndex connectToField in the from collection
Org chart
connectFromField: "_id"
connectToField:
  "reportsTo"

Manager → reports

Comments
connectFromField: "_id"
connectToField:
  "parentId"

Thread replies

2 levels only
maxDepth: 1

Direct + one hop

Active only
restrictSearchWithMatch:
  { active: true }

Filter during walk

🧰 Parameters

Key options inside the $graphLookup document:

startWith Required

Expression on the input document that seeds the first search—commonly "$_id" or a foreign-key field.

startWith: "$_id"
connectFromField / connectToField Required

Define the edge: find docs where connectToField equals the current value, then read connectFromField for the next hop.

connectFromField: "_id"
connectToField: "reportsTo"
as Required

Output array field name on each input document containing all documents found during recursion.

as: "allReports"
maxDepth Optional

Maximum recursion depth (zero-based). maxDepth: 0 = only documents directly connected to startWith.

maxDepth: 2

Examples Gallery

Sample employee org chart—load hierarchy data, fetch all reports recursively, cap depth, label levels, and filter to active staff only.

📚 Getting Started

Employee hierarchy with manager links.

Example 1 — Employees collection (org chart)

mongosh
db.employees.drop()
db.employees.insertMany([
  { _id: 1, name: "Alice Chen",   title: "VP Engineering", reportsTo: null, active: true },
  { _id: 2, name: "Bob Smith",    title: "Eng Manager",    reportsTo: 1,    active: true },
  { _id: 3, name: "Carol Diaz",   title: "Eng Manager",    reportsTo: 1,    active: true },
  { _id: 4, name: "Dave Kim",     title: "Senior Dev",     reportsTo: 2,    active: true },
  { _id: 5, name: "Eve Patel",    title: "Dev",            reportsTo: 2,    active: true },
  { _id: 6, name: "Frank Lee",    title: "Senior Dev",     reportsTo: 3,    active: true },
  { _id: 7, name: "Grace Wu",     title: "Intern",         reportsTo: 4,    active: false }
])

// Index connectToField for faster graph walks
db.employees.createIndex({ _id: 1 })
db.employees.createIndex({ reportsTo: 1 })

How It Works

Each employee (except Alice, the VP) has reportsTo pointing to their manager’s _id. This parent-pointer pattern is the most common setup for $graphLookup.

Example 2 — All reports under Alice (full recursion)

mongosh
db.employees.aggregate([
  { $match: { name: "Alice Chen" } },
  {
    $graphLookup: {
      from: "employees",
      startWith: "$_id",
      connectFromField: "_id",
      connectToField: "reportsTo",
      as: "allReports"
    }
  },
  {
    $project: {
      name: 1,
      title: 1,
      reportCount: { $size: "$allReports" },
      reports: {
        $map: {
          input: "$allReports",
          as: "r",
          in: { name: "$$r.name", title: "$$r.title" }
        }
      },
      _id: 0
    }
  }
])

/* Alice's allReports includes Bob, Carol, Dave, Eve, Frank, Grace
   (everyone who reports up through her chain) */

How It Works

MongoDB starts with Alice’s _id (1), finds employees where reportsTo === 1 (Bob, Carol), then uses each match’s _id (via connectFromField) to find Dave, Eve, Frank, and finally Grace—until no new matches appear.

📈 Practical Patterns

Depth limits, level tracking, and filtered traversals.

Example 3 — Direct reports only (maxDepth: 0)

mongosh
db.employees.aggregate([
  { $match: { name: "Bob Smith" } },
  {
    $graphLookup: {
      from: "employees",
      startWith: "$_id",
      connectFromField: "_id",
      connectToField: "reportsTo",
      as: "directReports",
      maxDepth: 0
    }
  },
  {
    $project: {
      name: 1,
      directReports: {
        $map: {
          input: "$directReports",
          as: "r",
          in: "$$r.name"
        }
      },
      _id: 0
    }
  }
])

// maxDepth: 0 → only Dave and Eve (report directly to Bob)
// Grace (reports to Dave) is excluded

How It Works

maxDepth is zero-based: 0 means one connection hop from startWith. Use maxDepth: 1 for direct reports plus their reports (two hops).

Example 4 — Label hierarchy level (depthField)

mongosh
db.employees.aggregate([
  { $match: { name: "Alice Chen" } },
  {
    $graphLookup: {
      from: "employees",
      startWith: "$_id",
      connectFromField: "_id",
      connectToField: "reportsTo",
      as: "allReports",
      depthField: "level"
    }
  },
  { $unwind: "$allReports" },
  {
    $project: {
      manager: "$name",
      reportName: "$allReports.name",
      reportTitle: "$allReports.title",
      level: "$allReports.level",
      _id: 0
    }
  },
  { $sort: { level: 1, reportName: 1 } }
])

/* level 0 → Bob, Carol (direct reports)
   level 1 → Dave, Eve, Frank
   level 2 → Grace */

How It Works

depthField adds a numeric depth to every document in the result array—handy for indented UI trees or grouping by org tier without post-processing in your app.

Example 5 — Active employees only (restrictSearchWithMatch)

mongosh
db.employees.aggregate([
  { $match: { name: "Alice Chen" } },
  {
    $graphLookup: {
      from: "employees",
      startWith: "$_id",
      connectFromField: "_id",
      connectToField: "reportsTo",
      as: "activeReports",
      restrictSearchWithMatch: { active: true }
    }
  },
  {
    $project: {
      name: 1,
      activeReportNames: {
        $map: {
          input: "$activeReports",
          as: "r",
          in: "$$r.name"
        }
      },
      _id: 0
    }
  }
])

// Grace (active: false) excluded from activeReports
// vs $lookup: one-level join only, no recursion

// Comment-thread variant (replies under a comment):
// connectFromField: "_id", connectToField: "parentId"
// startWith: "$_id" on the root comment document

How It Works

restrictSearchWithMatch filters candidates in the from collection at every recursion step—like an inline $match scoped to the graph search. Swap field names for comment threads (connectFromField: "_id", connectToField: "parentId") or category trees.

🧠 How $graphLookup Works

1

Evaluate startWith

MongoDB reads startWith from each input document—that value becomes the first search key.

Seed
2

Find connected docs

Search from where connectToField equals the current key. Apply restrictSearchWithMatch if set.

Match
3

Recurse

Read connectFromField from each match and repeat until no new docs or maxDepth is hit.

Loop
=

Array on input doc

All discovered documents land in the as array—optionally tagged with depthField.

📝 Notes

  • $graphLookup can search the same collection (self-referential) or a different one via from.
  • Index connectToField in the from collection—each hop is an equality lookup on that field.
  • Without maxDepth, recursion continues until the graph ends—risky on large or cyclic data (cycles are handled; duplicates are not re-added).
  • depthField values start at 0 for documents directly connected to startWith.
  • Results are a flat array, not nested—use depthField or post-process to build a tree UI.
  • Previous topic: $geoNear. Next: $group.

Conclusion

$graphLookup brings recursive graph traversal into aggregation: set startWith, wire connectFromField and connectToField, name your output with as, and optionally cap depth or filter matches.

Reach for $lookup when one join level is enough. Use $graphLookup when the relationship chain goes deeper. Next: $group for bucket aggregation.

💡 Best Practices

✅ Do

  • Index connectToField (and often connectFromField) in the from collection
  • Use $match before $graphLookup to reduce input documents
  • Set maxDepth when the UI only needs N levels of hierarchy
  • Use depthField for level-aware sorting or indentation
  • Apply restrictSearchWithMatch to exclude inactive or archived nodes

❌ Don’t

  • Use $graphLookup for simple one-level joins—use $lookup instead
  • Run unlimited recursion on very large graphs without testing performance
  • Swap connectFromField and connectToField—direction matters
  • Expect nested tree JSON—the output is a flat array unless you reshape it
  • Forget that maxDepth: 0 still returns directly connected documents

Key Takeaways

Knowledge Unlocked

Five things to remember about $graphLookup

Use these points whenever you traverse hierarchies in aggregation.

5
Core concepts
🔗 02

connect fields

Define edges.

Syntax
📈 03

maxDepth

Cap levels.

Control
📚 04

depthField

Level labels.

Output
🔍 05

vs $lookup

One vs many.

Compare

❓ Frequently Asked Questions

$graphLookup recursively searches a collection and returns all connected documents as an array. It walks a graph or tree—following links from one document to the next—ideal for org charts, comment threads, bill-of-materials, and category trees.
$lookup joins one level: each input document matches related documents once. $graphLookup keeps searching: it uses values from matched documents to find more matches, recursively, until no new connections exist or maxDepth is reached.
connectToField is the field in the from collection that must equal the current search value (e.g. reportsTo). connectFromField is read from each match and becomes the next search value (e.g. _id). MongoDB repeats: find docs where connectToField equals the previous connectFromField value.
maxDepth limits how many recursive hops $graphLookup performs. maxDepth: 1 returns direct connections only; maxDepth: 2 includes grandchildren. Omit maxDepth for unlimited depth (use carefully on large graphs).
Yes. The from option names the collection to traverse—often the same collection (self-referential org chart) but it can be any collection where connectFromField and connectToField define the relationship.
Index connectToField in the from collection, use maxDepth when you only need N levels, add restrictSearchWithMatch to narrow candidates, and $match early to reduce input documents before $graphLookup runs.
Did you know?

$graphLookup was introduced in MongoDB 3.4. The depthField option adds a zero-based level to each matched document—level 0 is the first hop from startWith. For very wide trees, consider storing a materialized path or using application-side batching. See the official $graphLookup docs.

Continue the Stages Series

Traverse hierarchies with $graphLookup, then aggregate buckets with $group.

Next: $group →

About the author

Mari Selvan M P
Mari Selvan M P 🔗

Developer, cloud engineer, and technical writer

  • Experience 12 years building web and cloud systems
  • Focus Full Stack Development, AWS, and Developer Education

I write practical tutorials so students and working developers can learn by doing—from databases and APIs to deployment on AWS.

5 people found this page helpful