MongoDB $graphLookup Stage

Beginner
5 min read
Updated: Oct 2026
3 examples
Live preview

What Is $graphLookup?

$graphLookup is a MongoDB aggregation stage that recursively walks relationships and returns every connected document in one array—org charts, nested comments, category trees, without app-level loops.

Remember
$graphLookup = "follow the links, hop by hop"

{
  $graphLookup: {
    from: "employees",
    startWith: "$_id",
    connectFromField: "_id",
    connectToField: "reportsTo",
    as: "allReports"
  }
}

$lookup  → one hop
$graphLookup → many hops
maxDepth: 0 → direct links only
depthField → label each hop level
index connectToField for speed

Same collection or a different from collection
UsePrefer
Multi-level hierarchy / graph walk$graphLookup
Single-level join$lookup
Group after you have the array$group

How to Use $graphLookup in MongoDB

Pick a start value, define the edge fields, then walk until depth or links end.

  1. Filter the starting document(s) with $match (e.g. the manager).
  2. Set from, startWith, connectFromField, connectToField, and as.
  3. Optional: maxDepth, depthField, restrictSearchWithMatch.
  4. Shape the result with $project (often map names from the array).
OptionRequired?Role
fromYesCollection to traverse
startWithYesExpression for the first search value
connectFromFieldYesField on each match → next search value
connectToFieldYesField matched against the current value
asYesOutput array field name
maxDepthNoZero-based hop limit

Pseudocode

Pseudocode
for each input document:
  queue = [ evaluate(startWith) ]
  seen = empty
  depth = 0

  while queue not empty and depth <= maxDepth (if set):
    matches = find in from where connectToField in queue
              (and restrictSearchWithMatch)
    nextValues = []
    for each match:
      if not seen:
        add match to result array
        nextValues += match.connectFromField
    queue = nextValues
    depth += 1

  document[as] = result array
  emit document

Cheat Sheet

Keep these facts handy while building hierarchy queries.

TopicDetail
TypeAggregation stage (recursive join)
vs $lookupMany hops vs one hop
OutputArray in the field named by as
maxDepth: 0Direct connections only
depthFieldAdds hop level on each matched doc
Index tipIndex connectToField (e.g. reportsTo)
Common modelChild points to parent (reportsTo)

Live Preview

Walk the sample org chart from a manager (teaching model of $graphLookup).

Edge: find employees where reportsTo equals the current _id, then hop via that employee’s _id.

Result
start: Alice Chen
allReports (6):
  Bob Smith, Carol Diaz, Dave Kim, Eve Patel, Frank Lee, Grace Wu

Worked Walkthrough — Alice’s Tree

Start at Alice (_id: 1). Follow everyone who reportsTo someone already found.

Calculation
startWith = Alice._id = 1

Hop 0: reportsTo == 1  → Bob, Carol
Hop 1: reportsTo == 2 or 3 → Dave, Eve, Frank
Hop 2: reportsTo == 4 → Grace

allReports = [Bob, Carol, Dave, Eve, Frank, Grace]

With maxDepth: 0 from Bob:
  only Dave, Eve  (Grace needs another hop)

MongoDB Examples

We use a small employees org chart. First fetch Alice’s full subtree, then limit Bob to direct reports. Run these in mongosh after inserting the sample data once.

Example 1 — Sample Data

Seven employees. Each person (except Alice) points reportsTo at their manager’s _id.

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 the field you match during the walk
db.employees.createIndex({ reportsTo: 1 })

How the Data Is Stored in MongoDB

After the insert, MongoDB keeps seven documents in employees. Run find() to see them:

mongosh
db.employees.find().sort({ _id: 1 })

Read the important fields like this:

FieldExamplePlain English
_id2Employee id (connectFromField)
reportsTo1Manager id (connectToField)
name"Bob Smith"Display label in results

How It Works

  1. Parent pointer. Children store the manager id in reportsTo.
  2. Alice is the root. Her reportsTo is null.
  3. Index helps. reportsTo is what each hop queries.

Example 2 — Full Recursion

Goal: start at Alice and collect everyone in her reporting tree.

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
    }
  }
])

How It Works

  1. Start document. $match keeps Alice only.
  2. First hop. Find rows where reportsTo equals Alice’s _id.
  3. Next hops. Use each match’s _id as the new search value.
  4. Stop. When no new reports appear, as: "allReports" holds the array.

Example 3 — maxDepth

Goal: from Bob, return only direct reports (maxDepth: 0. Grace (under Dave) is excluded.

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

// Optional: only active people during the walk
// restrictSearchWithMatch: { active: true }

How It Works

  1. Zero-based depth. maxDepth: 0 means only the first hop.
  2. Bob’s directs. Dave and Eve have reportsTo: 2.
  3. depthField. Each match gets level: 0 for that hop.
  4. Filter tip. restrictSearchWithMatch can skip inactive rows during the walk.

Edge Cases & Pitfalls

Check these before walking large graphs in production.

depth

maxDepth is zero-based

0 = direct links only. 1 = direct + one more level. Do not treat it like “number of people.”

order

Array order is not a tree walk guarantee

The as array is a set of connected docs. Sort or use depthField if the UI needs levels.

cycles

Cycles can inflate work

Bad data (A reports to B, B reports to A) can cause large traversals. Prefer clean trees and set maxDepth.

index

Index the connectToField

Each hop queries connectToField. Without an index, walks get expensive fast.

Performance Notes

ApproachCostBest for
$match then $graphLookupFewer starting rootsOne manager’s subtree
maxDepth setBounded hopsDirect reports / N levels
Unlimited depth, no indexHighAvoid on large collections

Index reportsTo (or your connectToField), filter roots early with $match, and prefer maxDepth when the UI only needs a few levels.

Key Takeaways

  • Role: recursive multi-hop relationship walk.
  • Edge: connectToField match → connectFromField next.
  • vs $lookup: many hops vs one hop.
  • Safety: set maxDepth; index the hop field.

One line: $graphLookup answers “who is connected through this chain?”

Frequently Asked Questions

$graphLookup recursively searches a collection and returns connected documents as an array. It walks a tree or graph—following links hop by hop—ideal for org charts, comment threads, and category trees.
$lookup joins one level: each input document matches related docs once. $graphLookup keeps searching: it uses values from matches to find more matches until no new links exist or maxDepth is reached.
connectToField is matched against 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 recursion hops. It is zero-based: maxDepth: 0 returns only direct connections; maxDepth: 1 includes the next level too. Omit it for unlimited depth (use carefully on large graphs).
Yes. from names the collection to traverse—often the same collection (self-referential org chart), but it can be any collection where the connect fields define the relationship.
Index connectToField in the from collection, set maxDepth when you only need N levels, use restrictSearchWithMatch to narrow candidates, and $match early so fewer input documents start a walk.

Next: $group

Group documents by a key and compute sums, averages, and other accumulators.

$group tutorial →

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.

9 people found this page helpful