Paths and Graph Traversals
Paths describe traversals through the graph. On top of fixed-hop
MATCH patterns, LoraDB supports variable-length
traversals, path binding, and shortest-path search.
For an intuition-first walkthrough, see the Ten-Minute Tour → Walk multi-step patterns.
Overview
| Goal | Pattern |
|---|---|
| Fixed hop count | (a)-[:R]->(b)-[:R]->(c) |
| 1 to 3 hops | (a)-[:R*1..3]->(b) |
| Exactly 3 hops | (a)-[:R*3..3]->(b) |
| Up to 3 hops | (a)-[:R*..3]->(b) |
| 3 or more hops | (a)-[:R*3..]->(b) |
| Any positive number of hops | (a)-[:R*]->(b) |
Zero-or-more hops (includes a) | (a)-[:R*0..]->(b) |
| Single shortest path | shortestPath(…) |
| All shortest paths | allShortestPaths(…) |
| Pattern existence, no rows | EXISTS { (…)-[…]->(…) } |
| Attach 1-to-many inline | Pattern comprehension |
Variable-length relationships
Cypher's * operator marks a relationship as variable-length — the
traversal can cross any number of that relationship between the given
bounds.
-- 1 to 2 hops
MATCH (a)-[:FOLLOWS*1..2]->(b) RETURN a, b
-- Exactly 3 hops
MATCH (a)-[:FOLLOWS*3..3]->(b) RETURN b
-- Up to 3 hops (same as 1..3)
MATCH (a)-[:FOLLOWS*..3]->(b) RETURN a, b
-- 3 or more, unbounded
MATCH (a)-[:FOLLOWS*3..]->(b) RETURN a, b
-- Any positive number of hops
MATCH (a)-[:FOLLOWS*]->(b) RETURN a, b
-- Zero-hop included — `a` itself matches as `b`
MATCH (a)-[:FOLLOWS*0..1]->(b) RETURN b
Each match produces one row per distinct traversal, so a single a
reached via two separate paths appears twice. Use
DISTINCT on the RETURN when you only
care about which nodes are reachable, not how many ways.
Cycle avoidance
Within a single matched path, no relationship is traversed twice.
This makes *..N safe on cyclic graphs without explicit guard logic.
Nodes can still repeat on a single path — see
Unique nodes on path for the distinction.
Traverse any type
Drop the type to traverse any relationship kind:
MATCH (a)-[*1..3]-(b) RETURN a, b
This is rarely what you want on non-trivial graphs — the result set explodes fast.
Binding the path
MATCH p = (…)-[…]->(…) binds the whole path to p. Use the built-in
path functions to inspect it.
MATCH p = (a:User)-[:FOLLOWS*1..3]->(b:User)
RETURN length(p) AS hops,
nodes(p) AS via,
relationships(p) AS rels
Path functions
| Function | Returns |
|---|---|
length(p) | Number of relationships on the path (Int) |
nodes(p) | List of nodes, start to end |
relationships(p) | List of relationships, in traversal order |
length(p) for a zero-hop path is 0; nodes(p) has one element; relationships(p) is empty.
Project intermediate nodes
MATCH p = (a:City {name: 'Amsterdam'})-[:ROUTE*2..3]->(b:City)
RETURN a.name, b.name,
[n IN nodes(p) | n.name] AS via
Shortest paths
Single shortest path
Returns one path of minimum length per (start, end) pair.
MATCH p = shortestPath(
(a:Station {name: 'Amsterdam'})-[:ROUTE*]->(b:Station {name: 'Den Haag'})
)
RETURN p, length(p)
Hop count here is the number of :ROUTE relationships traversed —
every relationship has cost 1, regardless of any weight property.
Ties are broken arbitrarily: if two paths have the same minimum
length you get one of them, not both — use
allShortestPaths when you need every
minimum-length path.
All shortest paths
Returns every path tied for the minimum length.
MATCH p = allShortestPaths(
(a:Station {name: 'Amsterdam'})-[:ROUTE*]->(b:Station {name: 'Den Haag'})
)
RETURN p, length(p)
Reachability check
MATCH p = shortestPath((a:Node {id: $src})-[*]-(b:Node {id: $dst}))
RETURN length(p) AS hops
One row back when a path exists; zero rows when nothing connects
a and b — the outer MATCH emits nothing, so the RETURN never
runs. Wrap with OPTIONAL MATCH (see
Disconnected nodes below) if you want a row
back with hops = null for the unreachable case.
Shortest path with type filter
MATCH p = shortestPath(
(a:User {id: $from})-[:FOLLOWS*]->(b:User {id: $to})
)
RETURN length(p)
Only :FOLLOWS edges count as hops.
Patterns in expressions
Pattern comprehension
Attach a one-to-many result inline — no extra MATCH needed. See also
List Functions → pattern comprehension.
MATCH (p:Person)
RETURN p.name,
[(p)-[:KNOWS]->(f) | f.name] AS friends
With a filter:
MATCH (p:Person)
RETURN p.name,
[(p)-[:WROTE]->(post:Post) WHERE post.published | post.title] AS posts
Existence check
Use EXISTS { pattern } to filter rows by
whether a pattern matches — without introducing extra rows.
MATCH (n:User)
WHERE EXISTS { (n)-[:FOLLOWS]->() }
RETURN n
Common patterns
Friends-of-friends
MATCH (me:User {id: $id})-[:FOLLOWS*2..2]->(fof)
WHERE fof <> me
RETURN DISTINCT fof.name
*2..2 forces exactly two hops — direct follows don't count. A
given fof can be reached through multiple mutual friends, so
DISTINCT collapses those duplicates to one row per person. The
fof <> me guard drops cycles back to the starting user.
Reachable set
MATCH (start:Node {id: $id})-[*0..5]-(n)
RETURN DISTINCT n
Every node within 5 hops, in any direction. *0..5 includes the
start node itself (via the zero-hop interpretation), so n covers
start plus everything reachable within five relationships.
Unbounded range (*0..) works but will expand the entire connected
component — keep a cap.
Shortest route length
MATCH p = shortestPath(
(a:City {name: $from})-[:ROAD*]->(b:City {name: $to})
)
RETURN length(p) AS hops
All nearby neighbors of each node
MATCH (n:Node)
RETURN n,
[(n)-[*1..2]-(m) | m] AS within_two_hops
Path with property check along the way
MATCH p = (a:User {id: $from})-[:FOLLOWS*1..3]->(b:User {id: $to})
WHERE all(r IN relationships(p) WHERE r.active)
RETURN p
Filter the whole path with list predicates
like all, any, none.
Path with minimum intermediate-property value
MATCH p = (a:Station {code: $from})-[:ROUTE*1..6]->(b:Station {code: $to})
WHERE all(r IN relationships(p) WHERE r.status = 'open')
WITH p, reduce(cost = 0, r IN relationships(p) | cost + r.km) AS km
ORDER BY km ASC
LIMIT 1
RETURN p, km
A manual "cheapest path of length ≤ 6" — shortestPath counts hops
only, so for weighted traversals you reduce
the relationship list yourself, then pick the minimum.
Excluded node on path
MATCH p = (a:User {id: $from})-[:FOLLOWS*]->(b:User {id: $to})
WHERE none(n IN nodes(p) WHERE n.blocked)
RETURN p
Path must not pass through any blocked user.
Unique nodes on path
Why duplicates happen
Variable-length patterns guarantee no relationship is traversed
twice on a single path (see cycle avoidance) —
but the same node can still appear more than once when two
different edges reach it. On a triangle A → B → C → A, the path
A → B → C → A visits A twice via two distinct edges.
This is path uniqueness (always enforced) vs node uniqueness (not enforced). For "pure" acyclic walks you need to filter node uniqueness yourself.
The duplicate in practice
CREATE
(a:Stop {code: 'A'}),
(b:Stop {code: 'B'}),
(c:Stop {code: 'C'}),
(a)-[:NEXT]->(b),
(b)-[:NEXT]->(c),
(c)-[:NEXT]->(a)
MATCH p = (:Stop {code: 'A'})-[:NEXT*1..4]->(end)
RETURN [n IN nodes(p) | n.code] AS path
| path |
|---|
['A', 'B'] |
['A', 'B', 'C'] |
['A', 'B', 'C', 'A'] ← A repeats |
['A', 'B', 'C', 'A', 'B'] ← A and B repeat |
The third and fourth rows are the "node-repeats" cases.
Workaround: filter on size equality
Collect node ids into a list, then require the list size to match the
size of its de-duplicated form. LoraDB has no distinct(list)
helper, so express dedup as "keep only elements whose first
occurrence is at their own index":
MATCH p = (:Stop {code: 'A'})-[:NEXT*1..4]->(end)
WITH p,
[n IN nodes(p) | id(n)] AS ids
WITH p, ids,
[i IN range(0, size(ids) - 1) WHERE NOT ids[i] IN ids[..i]
| ids[i]] AS unique_ids
WHERE size(ids) = size(unique_ids)
RETURN [n IN nodes(p) | n.code] AS path
Same graph:
| path |
|---|
['A', 'B'] |
['A', 'B', 'C'] |
Cycles filtered.
Mental model
Treat nodes(p) as a list. Every list-level operation you'd reach for
in the List Functions page works on it. When a
query asks "are all elements unique?" there's no single function
— you express it by comparing size(list) with the size of the
filtered "first-occurrence-only" form.
Why this is awkward (and a future-facing note)
This pattern is harder to read than it should be because there's no built-in
distinct(list)orcollect(DISTINCT …)helper over a literal list.collect(DISTINCT …)is an aggregate and only applies to rows. A future helper — for exampledistinct_list(xs)— would let the pattern collapse toWHERE size(ids) = size(distinct_list(ids)). Until then, the comprehension form above is idiomatic.
See Limitations for the current list-function gaps.
Related
- List comprehension — the syntax used for the "first-occurrence-only" filter.
collect(DISTINCT …)— distinct values when rows are your input, not a literal list.UNWIND + collect(DISTINCT …)— the row-level workaround.- DISTINCT on
RETURN— dedup whole output rows. - Cycle avoidance — why relationship uniqueness is automatic.
Longest path up to N
shortestPath has no "longest" counterpart. Bound the depth, collect,
and sort:
MATCH p = (a:User {id: $id})-[:FOLLOWS*1..5]->(b:User)
WITH a, b, p
ORDER BY length(p) DESC
LIMIT 1
RETURN p, length(p) AS hops
Expensive on dense graphs — prefer a bounded variable-length match
with a small *..N cap.
Edge cases
Disconnected nodes
If no path connects a and b, the MATCH emits zero rows. A
following RETURN length(p) never runs. Wrap with
OPTIONAL MATCH if you still want a row:
MATCH (a:User {id: $from}), (b:User {id: $to})
OPTIONAL MATCH p = shortestPath((a)-[:FOLLOWS*]->(b))
RETURN a, b, length(p) AS hops -- hops = null if unreachable
Self-loops
(a)-[:R*1..]->(a) matches only if there's a cycle back to a.
(a)-[:R*0..]->(a) always matches a via the zero-hop interpretation.
Performance
Unbounded variable-length traversals can be expensive. Bound with a maximum depth whenever the answer is "and not further":
-- Good — bounded
MATCH (a)-[:KNOWS*1..6]-(b) …
-- Risky on large graphs — unbounded
MATCH (a)-[:KNOWS*]-(b) …
Zero-hop semantics
[:R*0..N] includes the start node itself as a valid b via a
zero-hop "path". nodes(p) has one element, relationships(p) is
empty. Useful when the answer may be "myself plus my neighbors within
N".
What's not supported
- Weighted shortest paths.
shortestPathtreats every relationship as cost 1 — no cost argument, no Dijkstra-style weighting. Compute weighted paths in host code (or viareduceoverrelationships(p)). - Quantified path patterns (
((:X)-[:R]->(:Y)){1,3}) — not in the grammar. - Procedures like
apoc.path.*— noCALLsurface. - Inline
WHEREinside*patterns — parsed but not evaluated. Move the predicate into a standaloneWHEREusingnodes(p)/relationships(p).
See Limitations for the full list.
See also
- MATCH — underlying pattern language.
- WHERE — path predicates with
all/any/EXISTS. - RETURN / WITH — project path contents.
- List Functions → Pattern comprehension.
- Functions → Entity introspection —
id,labels,type. - Graph Model → Relationship semantics.