In short

Relational databases can store and query graphs — edges(src_id, dst_id, type) plus a B-tree on src_id is correct, queryable, and cheap for single-hop traversals. The wheels come off the moment the question asks about paths. "Friends of friends" needs one self-join. "Friends of friends of friends" needs two. An N-hop traversal needs N self-joins on the same edges table, the SQL grows unwieldy, and — worse — the optimiser cannot reason about path shape. Each join is a hash or sort-merge step that materialises an intermediate result; row counts compound multiplicatively (with fan-out 12 a 3-hop expansion produces ~1,728 candidate paths before DISTINCT), and the planner has no specialised "follow a chain of edges" operator. SQL:1999 added WITH RECURSIVE for unbounded depth: one CTE body re-applied to a fixed point. It collapses the syntactic explosion but not the cost — the executor still iterates depth by depth into a working table, with predicate push-down limited because the recursive reference is opaque to the planner. On a 100M-user, 5B-edge social commerce graph, the canonical 3-hop query "users who bought what your friends bought" takes Postgres around 30 seconds; Neo4j runs the same traversal as pointer chases in roughly 200 milliseconds — a ~150× gap. The Postgres ecosystem has narrowed it: Apache AGE embeds Cypher inside Postgres, pg_graph-style extensions add a graph-traversal physical operator, and ArangoDB ships a hybrid document-graph engine that picks per-query execution. None match a pure native engine on multi-hop, but they meaningfully close the gap when you cannot afford to operate two databases. Decision rule: one or two hops with aggregations — Postgres. Three-plus hops, path-finding, community detection, pattern matching with type constraints — Neo4j, TigerGraph, Memgraph. In between, prototype on Postgres with a recursive CTE, measure, migrate the traversal layer only if latency forces you to.

The previous chapter made the case for index-free adjacency at the storage layer: pointer-chasing instead of index-probing, ~1 µs per hop instead of ~100 µs. This chapter is about the query layer — what happens when you try to express a multi-hop traversal in SQL, why the syntax explodes, why the optimiser cannot rescue you, and what the SQL standard`s answer (recursive CTEs) does and does not solve.

The thesis is simple. SQL has exactly one primitive for "combine information from two rows": the JOIN. Joining the edges table to itself once gives two-hop reachability. Twice gives three-hop. N-hop reachability needs N self-joins. Around N=3 the query becomes a chore to write; around N=4 the optimiser starts producing defensible-looking plans that execute poorly; around N=5 you have given up and gone looking for a different database.

The schema and the one-hop query

The starting schema is the standard property-graph-on-Postgres layout:

CREATE TABLE nodes (id BIGINT PRIMARY KEY, label TEXT NOT NULL, properties JSONB);
CREATE TABLE edges (
  src_id BIGINT NOT NULL REFERENCES nodes(id),
  dst_id BIGINT NOT NULL REFERENCES nodes(id),
  type   TEXT   NOT NULL,
  properties JSONB,
  PRIMARY KEY (src_id, dst_id, type)
);
CREATE INDEX edges_dst_idx ON edges (dst_id);   -- for reverse traversal

A one-hop query — "who does Riya know?" — is a single index lookup:

SELECT e.dst_id FROM edges e WHERE e.src_id = 'riya' AND e.type = 'KNOWS';

Postgres descends the primary-key B-tree on (src_id, dst_id, type), finds the leaf page for ('riya', *, *), scans matching tuples, returns the destinations. Cost: ~100 µs warm. Why this is the floor: the only way to find Riyas edges is to look them up by source ID. The index makes that O(log N), but the lookup itself is unavoidable because edges live on different pages from nodes — there is no shortcut from Riyas row to her edges.

Two hops: the first self-join

Two hops — "who do Riya`s friends know?", the people-you-may-know primitive — is a self-join:

SELECT DISTINCT e2.dst_id
FROM   edges e1 JOIN edges e2 ON e1.dst_id = e2.src_id
WHERE  e1.src_id = 'riya' AND e1.type = 'KNOWS' AND e2.type = 'KNOWS';

The JOIN clause e1.dst_id = e2.src_id is the trick: every row of e1 is an outgoing edge from Riya; every row of e2 whose source matches e1.dst_id is an outgoing edge from one of Riyas friends. DISTINCTremoves duplicates when two friends know the same person. <span class="why">Why DISTINCT is mandatory: in a graph, a node reachable in two hops via three different paths shows up three times in the join output. Without de-duplication the result set bloats bydegree²`, and downstream consumers reason about path-multiplicities they never asked for.

Postgres treats this as a join of two relations, not graph traversal. A typical plan: scan the PK index on edges for e1.src_id = 'riya' (~12 rows for Riyas friends), then for each of those 12 rows probe the same PK index for e2.src_id = e1.dst_id(nested-loop with index on inner), filter bytype, sort and de-duplicate. Cost: 12 × 100 µs` plus a sort over ~144 rows ≈ 2 ms. Manageable. Why nested-loop with index-on-inner is the right call here: the outer relation (12 rows) is tiny and the inner has a usable index on the join key; hash-join would build a useless hash table over the entire edges table.

Three hops: the second self-join, and the syntax starts to bite

Three hops — "who do Riyas friends friends know?" — needs a third self-join:

SELECT DISTINCT e3.dst_id
FROM   edges e1
JOIN   edges e2 ON e1.dst_id = e2.src_id
JOIN   edges e3 ON e2.dst_id = e3.src_id
WHERE  e1.src_id = 'riya'
  AND  e1.type = 'KNOWS' AND e2.type = 'KNOWS' AND e3.type = 'KNOWS'
  AND  e3.dst_id <> 'riya';

Three aliases, joined head-to-tail, each with its own type filter. A four-hop query adds e4; a five-hop adds e5; you end up with a railway-timetable query. Why the type filter repeats on every alias: SQL has no "all edges in this path are of type KNOWS" operator — each predicate binds to one table reference. A heterogeneous path (KNOWS then BOUGHT then VIEWED) needs different filters on different aliases, and Cyphers compact [*1..3]` quantifier has no SQL equivalent.

N-hop traversal as N self-joins on the edges tableDiagram showing three copies of the same edges table, labelled e1, e2, and e3, drawn as stacked rectangles. Arrows between them show the join conditions: e1.dst_id equals e2.src_id (first join), and e2.dst_id equals e3.src_id (second join). Above the tables a horizontal axis labelled "hops" goes from 1 to 5, with annotations showing how many self-joins each hop count requires (1 hop = 0 joins, 2 hops = 1 join, 3 hops = 2 joins, 4 hops = 3 joins, 5 hops = 4 joins). Below the tables a quadratic explosion is illustrated: at the bottom is a callout reading "syntax grows linearly in N, but candidate paths grow as fanout to the N".Each hop is one more self-join on edgeshops:123450 joins1 join2 joins3 joins4 joinsedges e1src_iddst_idtyperiyaamitKNOWSriyadivyaKNOWSriyakaranKNOWSRiya`s edgesedges e2src_iddst_idtypeamitpriyaKNOWSdivyavikasKNOWSkaranpriyaKNOWSfriends` edgesedges e3src_iddst_idtypepriyasunilKNOWSvikasmeeraKNOWSpriyataraKNOWSfriends-of-friends` edgese1.dst = e2.srce2.dst = e3.srcsyntax cost vs row costSyntax: linear in N — one new alias and one new ON-clause per hop.Row cost (before DISTINCT): multiplicative in N — fanoutN intermediate rows.For fan-out 12 and N=3: 12 × 12 × 12 = 1,728 candidate paths for one seed.For fan-out 12 and N=5: 248,832 candidate paths per seed — before any aggregation.
The query syntax grows linearly in the number of hops (one new alias and one new ON-clause per level), but the *intermediate row count* grows multiplicatively in the average degree. With fan-out 12, three hops produce 1,728 candidate paths per seed; five hops produce nearly a quarter-million. The `DISTINCT` step at the end materialises and de-duplicates all of them, regardless of how many actually mattered.

The cost: each join is a sort+scan or hash+probe

Each JOIN in the SQL becomes one physical join operator. Postgres picks per-join from three algorithms based on cardinality estimates. Nested loop with index probe (the 2-hop plan above): outer relation small, index on inner — cost outer_rows × inner_lookup_cost, ~1.2 ms for Riyas 12 friends. Beautiful when the outer is selective and an index exists. **Hash join**: build a hash table over one input, probe with the other — cost build_rows × hash_cost + probe_rows × probe_cost. Catastrophic when applied to a self-join on the entire edges table because billions of rows hash in memory or spill to disk. <span class="why">Why the planner sometimes picks hash and gets it wrong: cardinality estimation on self-joins is notoriously poor. Postgres assumes column independence and uses MCV statistics collected for single-column predicates. The estimate "rows where e1.dst_id = e2.src_id`" multiplies per-column selectivities, which underestimates join cardinality on skewed-degree graphs. The planner under-budgets, picks hash, spills, and latency collapses. Sort-merge join: sort both inputs on the join key, merge in one pass. Rarely the right plan on graphs — there is almost always a usable index, so nested-loop wins.

The deeper problem is not which join is picked but that the optimiser reasons about each join independently. A native graph engine knows that the joins form a path: it walks node by node, never materialising intermediate results, and short-circuits as soon as enough output rows exist. SQL has no path operator. Each join produces a complete intermediate relation that flows up the plan tree, even when the next join only needs a streaming subset.

Per-join cost: sort-or-hash plus probe, multiplied per levelDiagram showing a query plan tree for a 3-hop friend-of-friend-of-friend traversal. At the bottom, three index scans on the edges table feed into two stacked join nodes (each labelled either Nested Loop with index, Hash Join, or Sort-Merge), which feed into a Sort-and-Unique node at the top. Each join node is annotated with its cost formula and a typical timing in milliseconds. To the right, a stacked-bar chart compares total query latency at 1, 2, 3, and 4 hops, showing the relational latency growing from 1 millisecond at one hop to about 30 seconds at four hops on a 100M-user graph.Each join is a sort-or-hash; cost compounds per level3-hop query plan (Postgres)Sort + UniqueDISTINCT dst_idNested Loope2.dst = e3.srcNested Loope1.dst = e2.srcIndex Scane1 (riya)Index Probee2 (per row)Index Probee3 (per row)Per-join cost (warm cache): e1 scan ≈ 100 µs · e2 probe × 12 ≈ 1.2 ms e3 probe × 144 ≈ 14 ms · sort 1,728 rows ≈ 1 mstotal latency vs hops (100M-user graph)1234hops30 s1 s10 ms01ms15ms800ms30slog scale — each hop ≈ 30× slower
Postgres compiles the 3-hop query into a stack of nested-loop joins — sensible at this size — but the cost climbs rapidly. The first join probes 12 rows (Riya`s friends), the second probes 144 (friends of friends). At four hops the inner relation is 1,700+ rows and the index-probe cost dominates. The plan is not wrong; it is just executing a graph traversal in a row-store, paying B-tree descent cost on every hop, with no operator that understands "this is a path".

WITH RECURSIVE: SQL`s answer to unbounded depth

The N-self-join pattern is fine when N is fixed and small. It fails the moment the query is "find all reachable nodes" (N is unbounded) or "find the shortest path" (N is variable). The SQL standard added recursive common table expressions in SQL:1999 precisely for this:

WITH RECURSIVE friends(person, depth) AS (
    -- anchor: start at Riya, depth 0
    SELECT 'riya'::TEXT, 0
    UNION ALL
    -- recursive step: follow one more KNOWS edge from the working set
    SELECT e.dst_id, f.depth + 1
    FROM   friends f
    JOIN   edges   e ON f.person = e.src_id
    WHERE  e.type = 'KNOWS'
      AND  f.depth < 4
)
SELECT DISTINCT person FROM friends WHERE depth > 0;

The semantics are textbook fixed-point iteration. The anchor query produces an initial working set (just Riya at depth 0). The recursive query is applied to the working set repeatedly: each iteration takes the rows added in the previous step, joins them with edges, and produces the next layer. Iteration stops when the recursive step produces no new rows — or, more commonly, when an explicit termination predicate (depth < 4) cuts it off. Why the depth cap is essential in practice: in a graph with cycles (and every social graph has cycles), the naive recursive CTE never terminates. Postgres uses UNION ALL (not UNION) inside the recursion, which means duplicates are kept; the same node can re-enter the working set multiple times, each time spawning more recursive expansions. Without a depth cap or an explicit WHERE NOT EXISTS (SELECT 1 FROM friends ff WHERE ff.person = e.dst_id) deduplication clause, the query runs until the working table fills temp space and the server kills it.

The WITH RECURSIVE form is a real improvement: the syntax stays compact regardless of N, path length becomes a runtime parameter (depth < N) instead of a query rewrite, cycles can be handled cleanly, and the planner has one recursive operator to optimise rather than N+1 join nodes.

But the cost story has not fundamentally changed. The executor materialises a working table on each iteration, joins it with edges, appends to the intermediate output, swaps in the new rows as the next working set, and repeats. Each iteration is essentially one self-join, executed serially. The optimiser cannot push predicates into the recursion and cannot reorder iterations. WITH RECURSIVE matches the cost of N stacked self-joins to within a constant factor — better than writing them out, but not by much. Why the optimiser cannot push predicates into the recursion: the planner needs a stable cost estimate for the recursive body, but the body`s input depends on its previous output — a function of itself. Postgres treats the recursive reference as an opaque "scan working table" operator with conservative cardinality estimates. Pushing a predicate down would require fixed-point analysis the planner does not attempt.

WITH RECURSIVE: working-table iterationA horizontal flow diagram showing the WITH RECURSIVE execution model. On the left, an anchor node produces an initial working set containing just Riya. An arrow leads to a join box that joins the working set with the edges table to produce the next layer of friends. The output of the join is appended to a growing intermediate-result table at the bottom and also fed back as the new working set, with a curved feedback arrow. The cycle repeats four times (depth 1 through depth 4), with each iteration labelled by its expected row count: 1, 12, 144, 1728, 20736. Above the flow, an annotation reads "each iteration is essentially one self-join — N iterations cost roughly N self-joins". A boxed callout to the right contrasts this with native graph traversal: "Native engine: pointer-chase, no working table, no DISTINCT step at the end".Recursive CTE: iterate the body until depth limitanchor{riya, 0}JOIN edgesworking ⋈ edgeson person = srcfilter type, depthnew rowsdepth k+1becomes nextworking setfeedback: working ← newalso append → resultgrowing intermediate result tabledepth 0:1 depth 1:12 depth 2:144depth 3:1,728 depth 4:20,736 ≈ 22.6k rows materialisedwhy CTE doesn`t close the gap• Each iteration is a fresh self-join; no streaming across iterations.• Working table is materialised in temp space; hot pages may spill on large fan-out.• Optimiser cannot push the depth predicate into the join; it filters after the join produces rows.Native engine: pointer-chase per hop, no working table, no DISTINCT at the end — same query, ~100× less work.
The recursive CTE eliminates the syntactic explosion of N self-joins, but underneath it is *still* doing a self-join per iteration, materialising the working table to disk-or-temp on each round, and appending all rows to a growing intermediate result. The optimiser cannot push the depth predicate into the join body, cannot stream across iterations, and cannot use any specialised "follow a path" operator because none exists in the row-store engine.

Why native engines win on multi-hop

The native graph answer to the same query — MATCH (riya)-[:KNOWS*1..4]->(other) in Cypher — runs end-to-end in milliseconds, for three reinforcing reasons. Pointer chase per hop: each hop is one pointer dereference, not a B-tree probe — ~1 µs vs ~100 µs, compounded (previous chapter). Optimiser knows graph topology: [Neo4js planner](https://neo4j.com/docs/cypher-manual/current/planning-and-tuning/) maintains per-label degree statistics (average and max) and decides whether to expand from Riya outward, from other backward, or bidirectionally — choices SQLs join planner has no analogue for. Why supernode awareness matters: a query that traverses through a Bollywood-actor node with five million followers behaves catastrophically if the planner does not know to handle it specially. Native engines prune or warn at parse time; Postgres just produces a plan that estimates 12 friends, sees 5,000,000, and OOMs. Indexes are integral: in Neo4j the adjacency is the index — no separate structure to maintain, no plan-time choice about whether to use it. The storage layout is the index.

When SQL is good enough — and when it isn`t

Postgres remains the right tool for one- and two-hop queries ("show this user`s direct friends", "friends-of-friends with mutual count"): single self-join, single-millisecond latency on 100M-edge graphs, no operational overhead. It is also the right tool for aggregations over node attributes ("users in Mumbai joined last month") — a row-store seq-scan with hash aggregate, the bread-and-butter of relational engines, which native graph engines do poorly because nodes and properties live in two separate variable-length files. And it is the right tool for hybrid analytical-transactional workloads where 90 % of the queries are CRUD-plus-aggregations and only 10 % are traversal — the operational tax of running a second database is rarely worth recovering 20× on a sliver of the workload.

The decision flips when the requirements include three-or-more-hop traversal at interactive latency (live fraud detection, real-time recommendations), graph algorithms (Dijkstra, Louvain community detection, PageRank — expressible in recursive CTEs but an order of magnitude slower than native implementations in Neo4j Graph Data Science or [TigerGraphs GSQL library](https://docs.tigergraph.com/graph-ml/current/intro/)), or **declarative pattern matching with type and property constraints** (Cyphers (person)-[:WROTE]->(book)<-[:REVIEWED]-(critic {country: 'IN'}) becomes a sprawling unreadable WHERE-clause in SQL).

The Postgres ecosystem`s answer

The choice is not binary. Apache AGE (A Graph Extension) embeds Cypher inside Postgres: nodes and edges still live in Postgres tables, but the engine compiles Cypher into a plan with graph-aware operators on top of relational storage — closer to Neo4j on multi-hop, with one operational footprint. pg_graph-style extensions (covering several research and commercial designs) add a graph-traversal physical operator to the Postgres planner that streams paths without materialising intermediates the way recursive CTEs do. ArangoDB ships a hybrid document-graph engine where AQL supports both FOR doc IN collection and FOR v, e, p IN 1..3 OUTBOUND startNode GRAPH 'social', with the optimiser picking per-query execution. The interesting decision in 2026 is which point on the relational-to-native spectrum your workload sits on.

"Users who bought what your friends bought" on a 100M-user, 5B-edge graph

The recommendations team at an Indian social commerce company — call it ShopCircle, with 100 million registered users and a five-billion-edge social-and-purchase graph — needs to power a "friends-bought-this" carousel on the home screen. The query is exactly three hops:

  1. Start from the logged-in user U.
  2. Follow KNOWS edges to U`s friends F.
  3. Follow BOUGHT edges from F to products P.
  4. Filter to products U has not yet bought.

The graph: 100M user nodes, 4.7B KNOWS edges (avg degree 47 per user, with the usual long tail; some influencer accounts have 5M+ followers), 300M BOUGHT edges (avg 3 per user). The query has to return in under 500 ms to be served inline; anything slower and the carousel falls back to a precomputed list, which loses the personalisation that justified the engineering investment in the first place.

Postgres path with recursive CTE.

WITH RECURSIVE expand AS (
    SELECT %s::BIGINT AS node_id, 0 AS depth, ARRAY[%s::BIGINT] AS path
    UNION ALL
    SELECT e.dst_id, x.depth + 1, x.path || e.dst_id
    FROM   expand x JOIN edges e ON e.src_id = x.node_id
    WHERE  x.depth < 3
      AND  ((x.depth < 2 AND e.type = 'KNOWS')
       OR   (x.depth = 2 AND e.type = 'BOUGHT'))
      AND  NOT (e.dst_id = ANY(x.path))
)
SELECT DISTINCT node_id AS product_id FROM expand
WHERE  depth = 3 AND node_id NOT IN (SELECT product_id FROM purchases WHERE user_id = %s)
LIMIT 50;

The CTE expands two KNOWS hops then one BOUGHT hop; the path array prevents revisiting nodes. Measured on PostgreSQL 16 (384 GB RAM, NVMe, hot data in shared_buffers), the median latency is ~30 seconds. Depth-2 has roughly 47 × 47 ≈ 2,200 candidate nodes; depth-3 fans out to ~6,600 edges. With supernodes (a 5M-follower influencer in the friend chain), tail latency spikes to minutes. The team ran this as a nightly batch and cached the carousel.

Neo4j path. Same query in Cypher:

MATCH (me:User {id: $userId})-[:KNOWS*1..2]->(friend:User)-[:BOUGHT]->(product:Product)
WHERE NOT (me)-[:BOUGHT]->(product)
RETURN DISTINCT product LIMIT 50

On comparable hardware the median latency is ~200 milliseconds. The traversal walks Riyas adjacency list (47 pointer chases), then each friends — ~6,600 pointer chases at ~1 µs each, ~7 ms in the traversal kernel, the rest HTTP and query-parse overhead. Dense-node optimisation keeps tail latency under 500 ms even for influencer-connected users.

Apache AGE path (for teams avoiding a second database). Same Cypher embedded via AGE measures ~3 seconds — 10× better than vanilla Postgres, 15× slower than Neo4j. Acceptable for an internal dashboard, not for the home-screen carousel. ShopCircle`s actual production choice: Neo4j alongside Postgres, with Debezium replicating changes from the Postgres source-of-truth into Neo4j within ~1 second — paying the operational cost for the latency win.

The takeaway

Relational databases express graph queries fine on shallow traversal (one or two hops). The architecture stops scaling at three-plus hops because SQLs only join-across-rows primitive is the JOIN, and an N-hop traversal needs N self-joins. The syntax balloons; the optimiser cannot reason about path shape; each join is a sort-or-hash that materialises an intermediate result with multiplicative row counts; and WITH RECURSIVE`, while collapsing the syntax, does not collapse the cost.

Native engines win on multi-hop because their storage layout is the index — pointer-chase replaces B-tree probe per hop, the planner understands graph topology, and the query language has first-class path operators. The 30-second-vs-200-millisecond gap on the ShopCircle workload is the sharp edge of that argument: same data, same logical query, two orders of magnitude latency difference. The right answer in production is rarely "all of one or all of the other": Postgres remains the system of record for tabular truth and shallow queries; a native graph engine alongside it serves the path-heavy ones; CDC keeps them in sync. The middle ground — Apache AGE, pg_graph extensions, ArangoDB — narrows the gap when operating two databases is unaffordable. The next chapter (Neo4j internals and page-cache tuning) goes inside the native engine to see how the storage and buffer pool deliver that 100× per-hop speedup in practice.

References

  1. PostgreSQL Documentation, Recursive Queriespostgresql.org/docs/current/queries-with.html. The canonical reference on WITH RECURSIVE semantics and execution.
  2. Sun et al., SQL/PG: SQL Server Graph Extensions and the SQL on Graphs Programme, Microsoft Research — microsoft.com/en-us/research/publication/sql-on-graphs/. The research programme that introduced graph operators into a row-store optimiser.
  3. Robinson, Webber, Eifrem, Graph Databases (2nd ed., OReilly, 2015), Chapter 4 — [graphdatabases.com](https://graphdatabases.com/). The canonical exposition of why relational queries struggle with multi-hop, by Neo4js founders.
  4. Neo4j blog, Graph databases vs. relational databases: a benchmarkneo4j.com/blog/relational-database-vs-graph-database/. End-to-end timing comparisons across hop counts.
  5. Apache AGE Project, Quick Start and Cypher Referenceage.apache.org/age-manual/master/intro/overview.html. How Cypher embeds inside Postgres without leaving the relational engine.
  6. ArangoDB Documentation, Graphs in AQLarangodb.com/docs/stable/aql/graphs.html. Hybrid document-graph query execution.