Traverse AtlasMart category relationships recursively with explicit depth, tenant scope, deterministic presentation, and memory/explain evidence.

$graphLookup for Recursive Relationships and Traversal Boundaries

Batch heterogeneous writes safely, interpret partial success, compare ordered and unordered execution, and use modern cross-namespace bulk APIs without assuming all-or-nothing behavior.

Advanced95–130 minutes$graphLookup traversal-boundary labMongoDB 8.3.8 · mongosh 2.10.0Last reviewed: September 2026

Learning objectives

01

Explain recursive graph traversal in terms of startWith, connectFromField, connectToField, depth, and visited documents.

02

Bound recursive work with maxDepth and restrictSearchWithMatch rather than trusting a small development graph.

03

Keep tenant/security scope inside the recursive search so traversal cannot cross logical ownership boundaries.

04

Recognize that graphLookup result order is not guaranteed and must be explicitly sorted for presentation.

05

Use explain and spill evidence to assess recursive memory and operational cost on representative data.

Reproducible lab baseline

This lesson pins MongoDB Community Server 8.3.8 with mongodb/mongodb-community-server:8.3.8-ubuntu2204-slim and mongosh 2.10.0. The server is a disposable standalone published only on loopback 127.0.0.1:27058. Authentication and TLS are disabled only for this isolated lab. Feature Compatibility Version (FCV) and allowDiskUseByDefault are observed but never changed. Default read/write concern and primary read preference apply. Atlas, Search, KMS, Enterprise Advanced, and paid services are not required. Runtime output shown as “expected” is documentation-derived because this generation environment has no Docker/mongod/mongosh runtime.

Evidence, not screenshots

The exact optimizer tree, execution counters, spill fields, and stage-specific explain shape can vary with patch version, FCV, indexes, data distribution, and topology. The lesson therefore names the invariant to verify—matched documents, traversal set/depth, facet counts, window values, indexes used, disk-use evidence, and target collection state—instead of requiring byte-for-byte explain output.

1. AtlasMart problem: descendant categories, not an unbounded graph crawl

AtlasMart’s category tree is stored as parent references. A product-navigation request starting at “electronics” needs descendants, but only for the current tenant, only active categories, and only to a deliberate depth. $graphLookup performs recursive matching: it starts from startWith, matches that value against connectToField, then follows each match’s connectFromField to the next recursion step.

Term Operational meaning
startWith Seed value(s) from the input document.
connectToField Foreign field matched against the current frontier value.
connectFromField Value(s) extracted from each match to form the next frontier.
maxDepth Non-negative maximum recursion depth; depth 0 is one non-recursive lookup step.
depthField NumberLong depth added to each traversed document; first match is depth 0.
restrictSearchWithMatch Query-filter document applied during traversal; it is not an arbitrary aggregation-expression context.
fan-out Number of neighbors reached per node; fan-out compounded by depth can make traversal grow rapidly.

2. Seed a graph that can expose cross-tenant traversal

bash · isolated Chapter 09 Lesson 2 lab setup
docker rm -f atlasmart-mongo-ch09-l2 2>/dev/null || truedocker volume rm atlasmart-mongo-ch09-l2-data 2>/dev/null || truedocker run -d --name atlasmart-mongo-ch09-l2 \  -p 127.0.0.1:27058:27017 \  -v atlasmart-mongo-ch09-l2-data:/data/db \  mongodb/mongodb-community-server:8.3.8-ubuntu2204-slimmongosh "mongodb://127.0.0.1:27058/atlasmart?directConnection=true" --quiet --eval \'printjson({server:db.version(),hello:db.hello().isWritablePrimary}); printjson(db.getSiblingDB("admin").runCommand({getParameter:1,featureCompatibilityVersion:1,allowDiskUseByDefault:1}))' 
javascript · seed category graph and parent index
const c=db.categories_ch09_l2;c.drop();c.insertMany([ {_id:"electronics",tenantId:"tenant-a",parentId:null,active:true}, {_id:"phones",tenantId:"tenant-a",parentId:"electronics",active:true}, {_id:"laptops",tenantId:"tenant-a",parentId:"electronics",active:true}, {_id:"android",tenantId:"tenant-a",parentId:"phones",active:true}, {_id:"ios",tenantId:"tenant-a",parentId:"phones",active:true}, {_id:"refurb",tenantId:"tenant-a",parentId:"phones",active:false}, {_id:"gaming",tenantId:"tenant-a",parentId:"laptops",active:true}, {_id:"tb-phones",tenantId:"tenant-b",parentId:"electronics",active:true}, {_id:"tb-budget",tenantId:"tenant-b",parentId:"tb-phones",active:true}]);c.createIndex({tenantId:1,parentId:1});printjson({count:c.countDocuments({}),indexes:c.getIndexes()});
Deliberate edge case

Tenant-b also has a node whose parentId is electronics. This makes an unsafe traversal visibly cross the tenant boundary instead of letting a weak fixture hide the bug.

3. Safe traversal: tenant filter + active filter + depth bound

javascript · bounded tenant-safe graphLookup
const r=c.aggregate([ {$match:{_id:"electronics",tenantId:"tenant-a"}}, {$graphLookup:{   from:"categories_ch09_l2",   startWith:"$_id",   connectFromField:"_id",   connectToField:"parentId",   as:"descendants",   maxDepth:2,   depthField:"depth",   restrictSearchWithMatch:{tenantId:"tenant-a",active:true} }}, {$project:{_id:1,descendants:{$sortArray:{input:"$descendants",sortBy:{depth:1,_id:1}}}}}]).toArray();printjson(r);
text · expected traversed set
depth 0: laptops, phonesdepth 1: android, gaming, iosrefurb is excluded because active=falsetenant-b nodes are excluded by restrictSearchWithMatch

The as array is not guaranteed to be ordered. The projection uses $sortArray only for deterministic display. Business logic must not infer path order from the raw array position.

4. Controlled failure: omit tenant restriction

The unsafe version still starts from a tenant-a document, but recursive matching is based on relationship fields, not an implicit ownership boundary. Because tenant-b has a child whose parentId matches the same root, it appears in the traversal.

javascript · unsafe traversal that crosses tenant ownership
const unsafe=c.aggregate([ {$match:{_id:"electronics",tenantId:"tenant-a"}}, {$graphLookup:{from:"categories_ch09_l2",startWith:"$_id",connectFromField:"_id",connectToField:"parentId",as:"descendants",maxDepth:2}}, {$project:{_id:1,"descendants._id":1,"descendants.tenantId":1}}]).toArray();printjson(unsafe);
Repair

Put tenant and status/lifecycle constraints in restrictSearchWithMatch when those constraints must hold at every recursion step. Filtering the resulting array afterward can already have traversed and exposed data that should never have entered the result set.

5. maxDepth is a resource and semantics control

javascript · compare depth bounds on the same graph
for(const d of [0,1,2]){ const x=c.aggregate([  {$match:{_id:"electronics",tenantId:"tenant-a"}},  {$graphLookup:{from:"categories_ch09_l2",startWith:"$_id",connectFromField:"_id",connectToField:"parentId",as:"d",maxDepth:d,depthField:"depth",restrictSearchWithMatch:{tenantId:"tenant-a",active:true}}},  {$project:{count:{$size:"$d"},ids:"$d._id"}} ]).toArray()[0]; print(`maxDepth=${d}`); printjson(x);}
text · expected tenant-a active counts
maxDepth=0 -> 2 descendantsmaxDepth=1 -> 5 descendantsmaxDepth=2 -> 5 descendants (no deeper active nodes in this fixture)

Depth is not just a performance knob. It changes the business result. If a page promises “two levels of subcategories,” encode that contract explicitly rather than relying on the current graph’s accidental height.

6. Memory, disk use, and explain evidence

Under the current MongoDB 8.3 documentation, a $graphLookup stage that exceeds 100 MB of memory can write temporary files when disk use is allowed; with allowDiskUse:false, exceeding the threshold returns an error. The tiny fixture will not force a spill. Use representative fan-out/depth distributions and inspect executionStats, server metrics, temporary-file evidence, and latency percentiles.

javascript · capture graphLookup execution evidence
const e=c.explain("executionStats").aggregate([ {$match:{_id:"electronics",tenantId:"tenant-a"}}, {$graphLookup:{from:"categories_ch09_l2",startWith:"$_id",connectFromField:"_id",connectToField:"parentId",as:"d",maxDepth:2,depthField:"depth",restrictSearchWithMatch:{tenantId:"tenant-a",active:true}}}],{allowDiskUse:true});printjson(e);
Version-sensitive behavior

The chapter pins 8.3.8 because memory/spill behavior is version-sensitive. Do not copy an older-version graphLookup memory rule into an 8.3 production runbook without re-checking current documentation.

7. Verification, cleanup, and production judgment

Verification checklist

  • Nine category documents exist, including inactive and other-tenant nodes.
  • The safe traversal returns only active tenant-a descendants.
  • The unsafe traversal visibly includes tenant-b data.
  • maxDepth=0 and maxDepth=1 produce different descendant counts.
  • Raw graphLookup output order is not treated as a path-order guarantee.
  • No claim of an actual disk spill is made from the tiny fixture.

Production judgment. $graphLookup is appropriate for bounded recursive relationships that remain selective enough to execute predictably. It does not turn a high-fan-out graph into a cheap tree query. On sharded deployments, MongoDB supports a sharded from collection starting in 5.1, but a graph lookup targeting a sharded collection cannot be used inside a transaction. Track traversal depth, visited cardinality, temporary-disk use, latency tails, and tenant-scope failures. For hot or repeated traversals, consider precomputed paths, materialized hierarchy fields, or a different data model rather than merely increasing resource limits.

Lesson 3 shifts from one recursive dimension to multiple analytic dimensions in a single $facet, where memory has a very different rule.

bash · cleanup / full reset
docker rm -f atlasmart-mongo-ch09-l2docker volume rm atlasmart-mongo-ch09-l2-data

Check your understanding

  1. What does maxDepth=0 mean?
  2. Why can starting from a tenant-a root still leak tenant-b nodes?
  3. Is the graphLookup output array guaranteed to be traversal order?
  4. Why should maxDepth be treated as part of business semantics?
  5. What current 8.3 behavior applies when graphLookup exceeds 100 MB and disk use is allowed?
Review the answers

One non-recursive lookup step: matching documents are depth 0 and no further recursion is followed.

Because recursive matching follows relationship-field values; ownership is not implicit. A foreign node with the same parent value matches unless the traversal filter excludes it.

No. Sort the array explicitly if presentation order matters.

It changes which descendants are considered part of the result, not only resource consumption.

The current 8.3 documentation says it can write temporary files; with allowDiskUse:false, exceeding the threshold errors.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.