Bounded Hops, Not Blind Ones: Variable-Length Paths in Neo4j Cypher
Cypher's variable-length path patterns answer 'what's connected within N hops' in one line — but the hop bound and relationship-type filter are what stand between a fast query and a full-graph scan.
13 Dec 2025, 05:18 UTC

Say you store a supply chain in Neo4j: parts, sub-assemblies, suppliers, factories. Someone asks, "Show me every supplier that could be affected if factory F shuts down, up to four levels deep." In SQL, that's a recursive CTE or a pile of self-joins. In Cypher it's one line — but that one line is also the easiest way to accidentally scan half your graph. The difference between a fast answer and a runaway query is usually a single number: the hop bound.
The pattern that does the work
Cypher's variable-length syntax lets you say "traverse this relationship type some number of times" directly in the pattern:
MATCH p = (f:Factory {name: 'F'})-[:SUPPLIES_PART|SHIPS_TO*1..4]-(x)
RETURN x.name AS affected, length(p) AS hops
The *1..4 means "follow these relationship types between one and four times." Neo4j walks the graph natively — it follows pointers from node to relationship to node — so there's no join to plan and no recursion to write. You get the reachability answer declaratively, and the path itself (p) is available if you want to inspect or return it.
Two details in that pattern matter more than they look. First, the bound 1..4 caps the expansion. Second, the relationship-type filter (SUPPLIES_PART|SHIPS_TO) restricts which edges the traversal may follow. Both shrink the search space before any filtering in WHERE happens.
Why the upper bound is your most important safety control
Drop the bound and write -[:SUPPLIES_PART*]- and you've asked Neo4j to explore the entire connected component reachable from your start node. On a dense graph, that can mean millions of relationship visits, ballooning memory and query time. The query isn't wrong — it's just unbounded, and unbounded traversals are a classic cause of production Neo4j incidents.
The key mental model: traversal cost scales with the number of relationships actually visited, not with the total size of your graph. A ten-million-node graph is fine if your start node has twelve neighbors. A small graph with a few supernodes — nodes with hundreds of thousands of relationships — can make even a *1..3 query expensive, because every hop through a supernode fans out enormously. That's a data-shape problem, not something an index fixes.
Practical rules of thumb:
- Always set an explicit upper bound, even a generous one like
*1..10. - Filter relationship types inside the pattern, not with a
WHERE type(r) = ...afterward — the latter still visits every relationship first. - If both endpoints are known, prefer
shortestPath()or a pattern anchored at both ends. Bidirectional search is typically far cheaper than expanding outward from one side.
A worked comparison: bounded vs. unbounded
Take a small social-style graph where (a:Person {name:'Ada'}) connects through KNOWS relationships. Compare these two queries in Neo4j Browser or cypher-shell:
PROFILE
MATCH (a:Person {name:'Ada'})-[:KNOWS*1..3]->(b)
RETURN count(DISTINCT b);
PROFILE
MATCH (a:Person {name:'Ada'})-[:KNOWS*]->(b)
RETURN count(DISTINCT b);
Run each with PROFILE (not just EXPLAIN — PROFILE actually executes the query and reports real work). Look at two numbers in the plan output: db hits, a rough measure of how much store access happened, and the row counts flowing between operators. On any graph with cycles or density, the unbounded version will show substantially more db hits, because it keeps expanding until nothing new is reachable. The bounded version stops at depth three.
You don't need a benchmark suite to see this — a few hundred nodes with a couple of deliberately high-degree "hub" nodes is enough. Create a hub with a few thousand KNOWS relationships and re-run both queries; the gap widens immediately, which is exactly the supernode effect you'll face in real data.
When the variable-length pattern is the wrong tool
Variable-length patterns are great for ad-hoc "what's connected within N hops" questions. They're less great when the same path query runs thousands of times a second, or when the depth is large. Alternatives worth knowing:
- Graph Data Science library: algorithms like BFS, shortest path (Dijkstra, A*), and community detection run over an in-memory projection and are built for heavy, repeated traversal workloads.
- Precomputed shortcut relationships: if "reachable within 4 hops" is a stable, frequently asked question, materialize it as a direct relationship (e.g.,
:AFFECTS) during ingestion or batch jobs, and query that instead. - Model restructuring: sometimes an intermediate node (a
Group, aRoute, aShipment) collapses a multi-hop pattern into a two-hop one.
The trade-off is freshness and storage: shortcuts and projections must be maintained as the graph changes, while the raw variable-length query always reflects current data.
Check it yourself before shipping
Before trusting any variable-length query in production: run it with PROFILE against a copy of real-shaped data, confirm the db hits stay sane as you raise the bound by one, and deliberately test against your densest nodes. Also confirm syntax and semantics against the Cypher manual for your exact Neo4j version — planner behavior, default path uniqueness, and available algorithms differ between 4.x and 5.x releases. The bound costs you nothing to add; the incident it prevents is expensive.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.