intermediate~3h

Neo4j — Traversals, Patterns & Common Query Shapes

The graph model from Chapter 01 earns its keep here — variable-length paths, shortest-path search, and the two query shapes ("recommend via shared connections," "find the fraud ring") that show up in real systems more than almost any other graph query.

Learning objectives

  • Write variable-length path queries to match relationships a flexible, bounded number of hops deep.
  • Find the shortest connection between two nodes using Cypher's shortest-path matching.
  • Recognize and write the two most common real-world graph query shapes: recommendation via shared connections, and fraud-ring detection.
  • Describe, at a conceptual level, what Neo4j's graph algorithm library adds beyond hand-written Cypher.

◆ The problem

Chapter 01's friends-of-friends-of-friends query hardcoded exactly 3 hops by chaining -[:FOLLOWS]-> three times in the pattern. Real features rarely want exactly one fixed depth — "people you may know" usually means "somewhere between 1 and 3 hops away," and hardcoding a separate query per depth doesn't scale as a codebase.

Cypher's variable-length relationship syntax handles this in one pattern: [:TYPE*minHops..maxHops].

MATCH (alice:Person {name: 'Alice'})-[:FOLLOWS*1..3]->(reachable:Person) RETURN DISTINCT reachable.name;

This matches every Person reachable from Alice via 1, 2, or 3 consecutive :FOLLOWS relationships, in a single traversal — Neo4j walks outward hop by hop and collects everything it finds within the bound, rather than you writing one chained pattern segment per depth. Leaving off the upper bound ([:FOLLOWS*1..], or even [:FOLLOWS*]) matches any depth at all — powerful, but worth treating carefully: on a densely connected graph, an unbounded traversal can fan out to a very large portion of the graph before it's done. In practice, put a sane upper bound on a variable-length pattern unless you have a specific reason not to.

▲ Common mistake

[:FOLLOWS*1..3] matches at any depth from 1 to 3, not exactly at depth 3 — if you specifically want only the outermost "third-degree" connections and not the closer ones too, filter separately (for instance, by also matching the shorter paths and excluding their endpoints) rather than assuming the range syntax means "exactly this far out."​

💻 Code example

MATCH (alice:Person {name: 'Alice'})-[:FOLLOWS*1..3]->(reachable:Person) RETURN DISTINCT reachable.name;

A common, genuinely different question from "what's reachable" is "what's the shortest connection between these two specific nodes" — degrees of separation, the fewest introductions needed, the shortest chain of transactions between two accounts. Cypher's shortestPath() function, used inside a path assignment, answers exactly this:

MATCH path = shortestPath( (alice:Person {name: 'Alice'})-[:FOLLOWS*1..6]-(bob:Person {name: 'Bob'}) ) RETURN path, length(path);

shortestPath() finds the single shortest path (by number of relationships) between the two anchored nodes, stopping as soon as it does — it does not enumerate every possible path first and then pick the shortest, which is what makes it practical on large graphs where the number of all paths between two nodes could be enormous. Bounding the hop count (*1..6 here) is good practice: it gives Neo4j a hard limit on how far to search before concluding no path exists, instead of exploring indefinitely on a graph where Alice and Bob genuinely aren't connected.

Note the relationship direction was left unspecified (-[:FOLLOWS*1..6]-, no arrowhead) — that matches a :FOLLOWS relationship in either direction, useful when "connected to" matters more than "who followed whom first." Keep the arrow (->) when direction is actually part of what you're asking.

💻 Code example

MATCH path = shortestPath( (alice:Person {name: 'Alice'})-[:FOLLOWS*1..6]-(bob:Person {name: 'Bob'}) ) RETURN path, length(path);

◆ Real-world example

"People you may know" and "products you might like" are both, underneath, the same graph query shape: find nodes that are two hops away through a shared connection, but not already directly connected to you.

MATCH (me:Person {name: 'Alice'})-[:FOLLOWS]->(friend:Person)-[:FOLLOWS]->(candidate:Person) WHERE candidate <> me AND NOT (me)-[:FOLLOWS]->(candidate) RETURN candidate.name, count(*) AS mutualConnections ORDER BY mutualConnections DESC LIMIT 10;

The two WHERE conditions are both doing real work: candidate <> me throws out Alice herself (she's trivially two hops from herself via any friend who follows back), and NOT (me)-[:FOLLOWS]->(candidate) — a relationship pattern used directly as a boolean condition — excludes people she already follows, since recommending an existing connection isn't useful. count(*) then tells you how many separate mutual friends led to each candidate, which doubles as a genuinely reasonable relevance ranking: more shared connections, better recommendation.

The same shape, product-recommendation flavored, swaps Person/FOLLOWS for Person/PURCHASED/Product:

MATCH (me:Person {id: $userId})-[:PURCHASED]->(:Product)<-[:PURCHASED]-(other:Person)-[:PURCHASED]->(rec:Product) WHERE NOT (me)-[:PURCHASED]->(rec) RETURN rec.name, count(DISTINCT other) AS boughtByHowManySimilarCustomers ORDER BY boughtByHowManySimilarCustomers DESC LIMIT 10;

This is collaborative filtering, expressed as one readable pattern: people who bought what you bought, what else did they buy, that you haven't bought yet.

💻 Code example

MATCH (me:Person {name: 'Alice'})-[:FOLLOWS]->(friend:Person)-[:FOLLOWS]->(candidate:Person) WHERE candidate <> me AND NOT (me)-[:FOLLOWS]->(candidate) RETURN candidate.name, count(*) AS mutualConnections ORDER BY mutualConnections DESC LIMIT 10;

◆ Real-world example

Fraud rings tend to leave two kinds of graph fingerprints: money cycling back to its source through a chain of accounts, and unrelated-looking accounts that turn out to share an identifying attribute they shouldn't — the same device, phone number, or payment card.

Cycles are a direct pattern match — a variable-length path that starts and ends at the same node:

MATCH (a:Account)-[:TRANSFERRED_TO*3..6]->(a) RETURN a.accountId;

This finds every account that's part of a money-transfer chain of 3 to 6 hops that eventually loops back to itself — a classic layering pattern used to obscure where money originally came from. A relational equivalent would need a recursive CTE (SQL Mastery, Chapter 10) checking, at every level of recursion, whether the current row's account matches the starting row's account — expressible, but noticeably more code for the same idea, and without the fixed-cost-per-hop traversal from Chapter 01.

Shared-attribute rings are a group-then-filter pattern: group by the shared attribute, and keep only the groups above a suspicious size.

MATCH (a:Account)-[:USES_DEVICE]->(d:Device) WITH d, collect(a) AS accounts WHERE size(accounts) > 3 RETURN d.deviceId, size(accounts) AS accountCount;

WITH here pipes the grouped-by-device results forward so WHERE can filter on the aggregate (size(accounts), built from collect()) — the same role HAVING plays after a GROUP BY in SQL, just expressed as a forward-piping clause rather than a dedicated post-aggregation keyword.

💻 Code example

MATCH (a:Account)-[:TRANSFERRED_TO*3..6]->(a) RETURN a.accountId;

◆ Real-world example

"Who are the most influential accounts in this network?" and "which accounts cluster into the same fraud ring, even without an obvious direct cycle?" are questions a single Cypher pattern doesn't answer well — they require looking at the whole graph's structure at once, not walking outward from one starting point.

Neo4j ships this class of analysis as a separate graph algorithm library — the Neo4j Graph Data Science (GDS) library — rather than as Cypher clauses. Conceptually, it covers two broad families worth being able to name in an interview:

  • Centrality algorithms (PageRank-style) — score every node by how "important" or well-connected it is within the graph, useful for ranking influential accounts, key intermediaries in a payment network, or hub products in a purchase graph.
  • Community detection algorithms (Louvain-style) — group nodes into densely-interconnected clusters, useful for surfacing fraud rings that don't form an obvious direct cycle, or for finding natural segments in a social or purchase graph.

The practical shape of using GDS: you first project the relevant slice of your graph into an in-memory, analytics-optimized structure, rather than running the algorithm directly against your live transactional graph, run the algorithm against that projection, then write the results back or stream them out. The exact procedure syntax is version-specific and worth looking up directly in Neo4j's GDS documentation when you need it — the interview-relevant takeaway is knowing this class of whole-graph analysis exists, is a genuinely different tool from pattern-matching Cypher, and roughly which family (centrality vs. community detection) fits which question.

Want a visual for this concept?

Generate a diagram tailored to “Neo4j — Traversals, Patterns & Common Query Shapes” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Neo4j — Indexes, Constraints & Query Performance← Back to all Neo4j chapters