Most real graph questions are traversal questions. Which finished products are affected if this component is recalled? Which upstream services fail if this database goes down? What is the cheapest route from A to B that avoids these three hops? For a decade the Cypher answer was the variable-length relationship — -[:PART_OF*1..5]-> — plus a pile of WHERE ALL(x IN nodes(p) ...) predicates bolted on afterwards, and shortestPath() when you needed the short one.
That era is over. Quantified path patterns (QPPs) landed in Neo4j 5.9, matured through the 5.x line, and are now the standard way to express multi-hop traversal in Cypher 25 — because they are what the ISO GQL standard specifies. They let you repeat a whole pattern, not just a single relationship, and they let you filter each repetition as the planner walks it instead of after the fact. Cypher 25 pairs them with a proper SHORTEST k selector that finally replaces shortestPath().
This tutorial rewrites four common traversal queries in the modern syntax, shows the performance reason it matters, and marks the line where you should stop writing Cypher and reach for GDS.
Prerequisites: Neo4j 5.9+ for QPPs, and Neo4j 2025.06+ with
CYPHER 25for the full path-selector syntax. Our Docker getting-started guide gets you a current instance in a few minutes, and CalVer, Cypher 25, and GQL explains the version model these features ship under.
A dataset to traverse
A small manufacturing bill of materials with suppliers — enough to make every query below runnable.
CREATE CONSTRAINT part_id IF NOT EXISTS
FOR (p:Part) REQUIRE p.id IS UNIQUE;
MERGE (a:Part {id: 'ASSY-1', name: 'Pump assembly', status: 'active'})
MERGE (b:Part {id: 'SUB-10', name: 'Motor module', status: 'active'})
MERGE (c:Part {id: 'SUB-11', name: 'Housing', status: 'active'})
MERGE (d:Part {id: 'CMP-100', name: 'Bearing', status: 'recalled'})
MERGE (e:Part {id: 'CMP-101', name: 'Seal ring', status: 'active'})
MERGE (f:Part {id: 'CMP-102', name: 'Rotor', status: 'obsolete'})
MERGE (d)-[:PART_OF {qty: 2}]->(b)
MERGE (f)-[:PART_OF {qty: 1}]->(b)
MERGE (e)-[:PART_OF {qty: 4}]->(c)
MERGE (b)-[:PART_OF {qty: 1}]->(a)
MERGE (c)-[:PART_OF {qty: 1}]->(a);
1. The quantified relationship: a drop-in upgrade
The smallest step is the quantified relationship. Old syntax on the left, new on the right:
// Legacy
MATCH (p:Part {id: 'CMP-100'})-[:PART_OF*1..5]->(top:Part)
RETURN top.name;
// Cypher 25
MATCH (p:Part {id: 'CMP-100'})-[:PART_OF]->{1,5}(top:Part)
RETURN top.name;
The {1,5} quantifier reads the same as a regular-expression quantifier: between one and five repetitions. {2,} means two or more, {,3} means up to three, + means one or more, * means zero or more. Prefer this form in new code: the *1..5 syntax still parses, but the deprecation notices have started and every GQL-aligned tool speaks quantifiers.
2. Quantified path patterns: filter every hop
Here is where QPPs earn their keep. Suppose you only want to traverse through parts that are still active — a recalled or obsolete sub-assembly breaks the chain. With the legacy syntax you match everything, then filter:
// Legacy: expand first, discard later
MATCH path = (p:Part {id: 'CMP-101'})-[:PART_OF*1..5]->(top:Part)
WHERE ALL(n IN nodes(path)[1..] WHERE n.status = 'active')
RETURN top.name;
The planner expands the full frontier and only then throws rows away. On a wide graph that is the difference between a 5 ms query and a timeout.
With a quantified path pattern you put the predicate inside the repeated unit, so the expansion prunes as it goes:
MATCH (p:Part {id: 'CMP-101'})
(()-[:PART_OF]->(n:Part WHERE n.status = 'active')){1,5}
(top:Part)
RETURN top.id, top.name;
Read the middle line as: repeat, one to five times, the pattern "one PART_OF hop into an active Part". Three rules to internalise:
- The repeated pattern must be wrapped in parentheses and must start and end with a node pattern —
(()-[:PART_OF]->(n)){1,5}, not(-[:PART_OF]->(n)){1,5}. - Predicates go inside the node or relationship pattern with the inline
WHERE, not in a trailingWHEREclause. - Variables bound inside a QPP come out as lists, in traversal order.
nabove is a list of parts, not a single part.
That last rule is the one that trips people up, and it is also the most useful. You can aggregate over the repetition directly:
MATCH (start:Part {id: 'CMP-100'})
(()-[r:PART_OF]->(n:Part)){1,5}
(top:Part)
WHERE NOT (top)-[:PART_OF]->()
RETURN top.name,
[x IN n | x.id] AS hops,
reduce(q = 1, rel IN r | q * rel.qty) AS total_qty,
size(r) AS depth
ORDER BY depth;
reduce over the relationship list multiplies quantities along the chain — the classic BOM roll-up, in one pattern, with no apoc.path procedure and no manual recursion.
Mixing fixed and repeated segments
A QPP is just one segment of a larger pattern, so you can bracket it with fixed hops. "Which suppliers ship anything that ends up in the pump assembly?" becomes:
MATCH (s:Supplier)-[:SUPPLIES]->(c:Part)
(()-[:PART_OF]->(:Part)){0,5}
(a:Part {id: 'ASSY-1'})
RETURN DISTINCT s.name, c.id;
The {0,5} lower bound of zero lets a directly supplied top-level part match too. That single query used to be two queries and a UNION.
3. SHORTEST k: retiring shortestPath()
Cypher 25 makes the path selector part of the pattern itself. Four selectors, all written immediately after MATCH:
| Selector | Meaning |
|---|---|
SHORTEST 1 | one shortest path (the shortestPath() replacement) |
SHORTEST k | the k shortest paths by length, ties broken arbitrarily |
ALL SHORTEST | every path tied for shortest (was allShortestPaths()) |
SHORTEST k GROUPS | all paths in the k shortest length groups |
ANY | any one path that matches — cheapest, when you only need existence |
// Legacy
MATCH p = shortestPath((a:Part {id:'CMP-100'})-[:PART_OF*..10]-(b:Part {id:'CMP-101'}))
RETURN p;
// Cypher 25
MATCH p = SHORTEST 1 (a:Part {id:'CMP-100'})-[:PART_OF]-{1,10}(b:Part {id:'CMP-101'})
RETURN [n IN nodes(p) | n.id] AS hops;
The payoff is that selectors compose with everything else. shortestPath() famously choked on predicates that referenced the path — you would get the dreaded fallback to an exhaustive search, or an error telling you to rewrite the query. Selectors have no such restriction:
MATCH p = SHORTEST 3 (a:Part {id:'CMP-100'})
(()-[:PART_OF]-(n:Part WHERE n.status <> 'obsolete')){1,10}
(b:Part {id:'CMP-101'})
RETURN [x IN nodes(p) | x.id] AS route, length(p) AS len
ORDER BY len;
Use ANY when the question is boolean — "is this component reachable from that assembly at all?" — because the planner stops at the first match instead of enumerating.
4. Reading the plan
Put PROFILE in front of both versions of the active-parts query and compare. The legacy form shows VarLengthExpand(All) feeding a Filter with a large db hits count; the QPP form shows a Repeat(Trail) (or Repeat(Walk)) operator with the predicate pushed into the expansion, and far fewer rows leaving it. Repeat(Trail) also guarantees relationship uniqueness within the repetition — no relationship is traversed twice inside one path — which is what stops cyclic graphs from exploding.
Two profiling habits worth keeping:
- Always give the quantifier an upper bound in production. An unbounded
+on a densely connected graph is how you discover your heap limits at 3 a.m. - Anchor at least one end of the pattern on an indexed lookup. A QPP starting from
AllNodesScanis still a full scan; see Tuning Slow Cypher for how to read the rest of the plan.
5. Where Cypher stops and GDS begins
QPPs are pattern matching. They are excellent for bounded, predicate-heavy traversal over a subgraph you can anchor. They are the wrong tool when:
- You need weighted shortest paths.
SHORTEST kcounts hops, not cost. For cheapest-by-weight routing use GDSgds.shortestPath.dijkstraorgds.shortestPath.yenson a projected graph. - The traversal is global. "Rank every part by how many products it reaches" is PageRank or WCC territory, not a per-row query — see our GDS recommendations tutorial for the projection workflow.
- You need exotic expansion control, such as node-label sequences or a repeat-visit policy that
Trailsemantics do not express.apoc.path.expandConfigstill has a place, though its footprint shrinks with every release.
A practical rule from our engagements: if the query starts from a handful of anchor nodes and finishes in milliseconds, keep it in Cypher; if it touches most of the graph or needs weights, project it into GDS.
Migration checklist
When we modernise a client codebase, we sweep for these in order:
-[:REL*1..n]->patterns whose results are filtered afterwards withALL(... IN nodes(p) ...)→ rewrite as a QPP with inline predicates. Biggest performance win.shortestPath()/allShortestPaths()→SHORTEST 1/ALL SHORTESTselectors.- Existence checks using a variable-length match plus
LIMIT 1→ANY. apoc.path.subgraphNodescalls that only implement a label or property filter → plain QPP.- Unbounded quantifiers → add an upper bound and a
PROFILEregression test.
Run each rewrite under PROFILE against a copy of production data before and after, and keep both queries in a test that asserts identical result sets. Quantified path patterns change the semantics subtly in cyclic graphs (trail vs. walk uniqueness), so "same rows, fewer db hits" is the acceptance criterion, not "looks equivalent".
Getting help
Rewriting a traversal layer touches the model, the indexes, and the application code at once. GraphGuru's senior Neo4j consultants do this work as short, fixed-scope engagements: a query audit, the rewrites, and the plan regression tests your team can keep running. If you have variable-length queries that got slower as the graph grew, get in touch with a couple of example queries and we will tell you what we would change.