Docs/Timebase Components/Traversing Large Graphs
AtlasHow-to

Traversing Large Graphs

How to explore a large Atlas domain in bounded steps: depth limits, paging with parameters, and the ring-by-ring pattern for subgraphs of any size.

Updated 2 Nov 2026Timebase Docs

Overview#

Atlas lets you traverse a domain's containment and feeds relationships to explore subgraphs of any shape and size. On a large model, the way you write the traversal decides how much you get back and how long it takes.

This article covers the three things that matter: the depth limits on a variable-length pattern, how to page a large result, and the ring-by-ring pattern that explores a subgraph of any depth in bounded steps.

Depth limits on variable-length patterns#

Every variable-length path in Atlas is traversed with a bounded depth-first search:

Bound Value When it applies
Default depth 10 hops When you write [:contains*] with no upper bound
Maximum depth 50 hops The ceiling, applied when you ask for more
Your pattern Depth traversed
[:contains*] 10 hops
[:contains*1..15] 15 hops
[:contains*1..100] 50 hops

A query that reaches either bound returns its results as HTTP 200 with valid rows, so write the depth bound yourself rather than relying on the default. A rollup total that is lower than expected is usually a depth bound you did not state.

Instead of:

MATCH (root:Site)-[:contains*]->(child)
WHERE root.Label = 'Plant North'
RETURN child.Label AS Child, labels(child) AS Type

write:

MATCH (root:Site)-[:contains*1..8]->(child)
WHERE root.Label = 'Plant North'
RETURN child.Label AS Child, labels(child) AS Type

The bound is now visible in the query. If you need to explore deeper, raise the number. If your model is deeper than 50 levels, use Walk outward one ring at a time below.

Repeated nodes and cycles#

The traversal uses trail semantics: the same relationship is never followed twice within one path. Uniqueness applies to relationships rather than to nodes, so a node can appear more than once in a path when it is reached along different relationships.

This shows up in cyclic models:

  • A diamond containment, where a pump sits in two lines that both feed the same tank, returns the tank once for each line-pump pair.
  • A feeds loop, where pump A feeds tank B, tank B feeds line C, and line C contains pump A, returns the pump once per circuit of the loop.

When a node appears several times in your result, check whether a cycle in the model explains it, and remove the duplicates in your report if you do not want them.

Page with parameters#

SKIP and LIMIT accept a numeric literal or a numeric parameter.

MATCH (n:Pump)
RETURN n.Label AS Pump, n.Speed.Value AS Speed
ORDER BY Pump
SKIP $offset
LIMIT $pageSize

Call it with {"offset": 0, "pageSize": 100}, then {"offset": 100, "pageSize": 100}, and so on. Keep the same ORDER BY across calls so page boundaries stay stable: no row then appears on two pages, and none is skipped.

Three things to send as pre-calculated values rather than writing into the query:

What you might write What to send instead
SKIP count(n) / 2 The computed number, as a parameter
SKIP $pageSize * ($pageNum - 1) The computed offset, as a parameter
{"skip": "20"} {"skip": 20} - a number, not a string

The third is worth watching for when page numbers arrive from a URL query string or a form field, where every value arrives as text. Convert before you send.

In Gage, the graph-query agent writes this pattern for you if you ask: "Show me the paging pattern for pumps, 50 rows per page, ordered by label."

Walk outward one ring at a time#

For a deep or wide subgraph, expanding in fixed rings gives you visible progress, a predictable cost per step, and state you can restart from. Start from an anchor, expand a small fixed depth, collect the frontier, and feed it back as a parameter for the next ring.

Step 1 - start at the anchor and expand one ring.

MATCH (pump:Pump)
WHERE pump.Label = 'Feed Pump-001'
MATCH (pump)-[:feeds|:contains*1..1]->(neighbor)
RETURN DISTINCT neighbor.entityId AS NextId

Step 2 - collect the frontier IDs. Say the query returned [inst-feed-pump-001, inst-tank-a, inst-line-01, inst-feed-pump-002]. Those are the frontier for the next step.

Step 3 - feed the frontier back as a parameter.

MATCH (node)
WHERE node.entityId IN $frontierIds
MATCH (node)-[:feeds|:contains*1..1]->(neighbor)
RETURN DISTINCT neighbor.entityId AS NextId

With {"frontierIds": ["inst-feed-pump-001", "inst-tank-a", "inst-line-01", "inst-feed-pump-002"]}.

Step 4 - repeat until the frontier is empty. Keep a visited set on your side so you do not re-explore the same nodes. When a ring returns nothing new, you have reached the edge of the subgraph.

You can expand more than one hop per ring - *1..5 works the same way - as long as you stay inside the 50-hop maximum.

What this gives you:

  • A predictable cost per step, so you can tell whether you are progressing.
  • A depth you control, ring by ring, with no bound to discover later.
  • Restartable state. If a ring does not complete, restart it from the last frontier you saved.
  • Bounded memory, because each ring returns a frontier rather than a whole subgraph.

Filter by type on the final node#

Write the type on the node at the end of the pattern:

MATCH (root:Site)-[:contains*1..8]->(n:Pump)
RETURN n.Label

The type filters the final node only, so this returns the pumps within eight levels of the site. The nodes in between are not constrained. This is covered in full in Querying with Cypher.

Set a client timeout and a row budget#

Give your HTTP client a timeout before you run an exploratory traversal:

  • A shallow, narrow subgraph (under 100 nodes, under 5 hops) returns in hundreds of milliseconds.
  • A moderately deep graph (1,000 nodes, 10 to 15 hops) takes a few seconds.
  • A wide exploration on a model you have not queried before: start at 30 seconds and watch server CPU and memory.

Decide how many rows you will accept before you fetch. If a query returns more, add a LIMIT, narrow the WHERE, or switch to the ring pattern above. This matters most in a reporting or scripting context, where an unexpectedly large result can fill the disk the report is writing to.

Troubleshooting#

Symptom Cause Fix
The result is shorter than expected, or a total is low A variable-length pattern reached its depth bound Write the bound explicitly: [:contains*1..15] rather than [:contains*]. Beyond 50 hops, use the ring pattern
400 Query.Unsupported - SKIP/LIMIT expression must be a constant integer An expression was written in SKIP or LIMIT Pass the computed value as a parameter: {"offset": 20, "pageSize": 10}
400 Query.Unsupported - SKIP/LIMIT parameter '$name' was not provided The parameter was not in the parameters object Check the spelling. Parameter names are case-sensitive
400 Query.Unsupported - SKIP/LIMIT parameter '$name' must be a numeric value, got JSON String The parameter arrived as a string Send it unquoted: {"skip": 20}
The query takes a long time to return It is building a very large result Add a LIMIT and try again, then widen once you know the result size
A node appears several times A cycle in the model reaches it along more than one path Expected under trail semantics. Remove the duplicates in your report

Before you run a traversal on a large model#

  • [ ] The depth bound is written in the query, not left to the default.
  • [ ] The bound is 50 hops or fewer. If more is needed, use the ring pattern.
  • [ ] There is a LIMIT on the query while exploring.
  • [ ] The HTTP client has a timeout.
  • [ ] There is a row budget, and a plan if the result exceeds it.
  • [ ] Paging uses parameters for SKIP and LIMIT.
  • [ ] Frontier nodes are saved if you are using the ring pattern.
esc
↑↓ navigate · ↵ open · ⌘K anywhere