Section 6: Traversal Surprises — When Queries Return More Than You Expect
One of the most common moments of confusion when writing graph traversals is running a query and getting significantly more results than you anticipated. This is not a bug. It is a fundamental property of how graph traversals work, and once you understand it, you can control it precisely.
The three-node problem
Consider a small, simplified graph with exactly three nodes — A, B, and C — where A and C both have outbound relationships pointing to B:
Now write a traversal: find any node that has an outbound relationship to a node that has an inbound relationship from any other node:
node().out().node().in_().node()
How many results would you expect?
You might say one: A→B←C.
The actual answer is four.
Why four?
A traversal finds every path through the graph that satisfies its pattern.
The pattern node().out().node().in_().node() says:
-
Start at any node
-
Walk to any node via an outbound relationship
-
Walk to any node via an inbound relationship
Let’s enumerate all valid paths:
| Start | Middle | End | Valid? |
|---|---|---|---|
A |
B |
C |
✅ A→B←C |
C |
B |
A |
✅ C→B←A (the reverse) |
A |
B |
A |
✅ A→B←A (loops back to itself) |
C |
B |
C |
✅ C→B←C (loops back to itself) |
All four are valid satisfactions of the pattern. The traversal found them all.
What this means in practice
In a real Apstra blueprint with 150+ nodes and hundreds of relationships, unqualified traversals can return thousands of results — not because there are thousands of unique objects, but because there are thousands of valid paths through the graph.
This is not incorrect data. But it is rarely what you want.
Narrowing with filters
The first line of defence is good filters on every node in the traversal. If you add type filters, the result set shrinks dramatically:
node(type='green', label='A')
.out()
.node(type='purple')
.in_()
.node(type='green')
Even with label='A' on the starting node, you still get two results: A→B→A and A→B→C.
Node A has an outbound relationship to B.
B has inbound relationships from both A and C.
So the query still finds two valid paths.
Adding type constraints and label filters is good practice, but it does not prevent loops or bidirectional paths on its own.
ensure_different
ensure_different() is chained at the end of a traversal to assert that two named nodes in the query cannot resolve to the same object.
node(type='green', label='A', name='start')
.out()
.node(type='purple')
.in_()
.node(type='green', name='end')
.ensure_different('start', 'end')
Now only A→B→C is returned.
The path A→B→A is eliminated because start and end would both resolve to node A — and ensure_different forbids that.
When would you use this? Any traversal that walks outward from a starting node and then back to a node of the same type risks finding the same node at both ends. The canonical example is a link traversal:
node(type='system', name='sys_a')
.out('hosted_interfaces')
.node(type='interface')
.out('link')
.node(type='link')
.in_('link')
.node(type='interface')
.in_('hosted_interfaces')
.node(type='system', name='sys_b')
.ensure_different('sys_a', 'sys_b')
Without ensure_different, this query would find cases where the same system appears at both ends of the path — which is structurally possible if a device has a link that loops back to itself in the model.
With ensure_different, only genuine peer-to-peer links are returned.
You can pass more than two names to ensure_different if your traversal visits the same node type three or more times.
distinct
distinct() is a separate mechanism that removes duplicate result rows based on the value of one or more named nodes.
match(
node('system', name='system', system_type='internal')
.out('hosted_interfaces')
.node('interface', if_type='port_channel', name='system_interface')
.in_('composed_of')
.node('interface', member_count=gt(1), name='target')
).distinct(['target'])
Here, .distinct(['target']) ensures that each unique target interface appears only once in the results, even if multiple traversal paths lead to the same node.
When would you use this?
distinct() is useful when a node can be reached via multiple valid paths in the traversal and you only want to count it once.
It differs from ensure_different in that ensure_different prevents loops within a single result row, while distinct() removes duplicate rows across the full result set.
Practical guidance
The three issues covered in this section — bidirectional traversals, self-loops, and duplicate rows — all have the same root cause: you described a pattern, and the graph found every path that satisfies it, not just the one you pictured.
Defend against unexpected results by following these habits:
-
Name the nodes you care about and confirm they are different objects when the traversal might loop.
-
Use
ensure_differentwhenever a traversal starts and ends on the same node type. -
Add type and role filters on every node in the traversal, not just the starting node.
-
Start small: run the query on a simple three-node test or a small rack first, inspect the results, then remove the narrowing filter to run it across the full blueprint.
-
Check the result count: if a three-hop traversal across a 20-leaf fabric returns 10,000 results, something is almost certainly over-matching.
Continue to Section 7: match() and optional() to learn how to combine multiple traversals into a single query — and how to handle path elements that may or may not exist.