Walking graphs in SQL
Recursive CTEs traverse trees, DAGs, and graphs — org charts, bills of materials, reachability — by accumulating a path array; but path explosion and per-hop joins mean SQL is the wrong tool past a few million edges.
A product team ships a feature graph: features depend on features, and they need “everything that breaks if we deprecate feature X.” It’s a reachability query over edges(src, dst), and a recursive CTE nails it in fifteen lines — until the graph grows to a few million edges and one query starts taking ninety seconds and 8 GB of work_mem. The SQL was never wrong; it hit the wall every graph-on-a-relational-database eventually hits. Knowing where that wall is — and how to push it back, and when to stop pushing — is the senior skill this lesson is about.
The three shapes: tree, DAG, graph
Before you write the recursion, ask: does this data form a tree, a DAG, or a general graph? The answer decides whether your query is naturally safe, needs a dedup step, or will hang on real data without an explicit brake. The same recursive engine handles three structures of increasing danger, all over edges(src, dst) (or manager_id, parent_id):
- a tree — each node has one parent (org chart,
categories). Traversal always terminates; no cycle risk. - a DAG — directed, no cycles, but a node can have many parents (a bill of materials: a bolt used by ten assemblies). Terminates, but the same node is reached by many paths, so you may visit it repeatedly.
- a general graph — directed with cycles possible (dependency graphs, social follows). Cycles mean you must carry cycle protection or the walk never ends, exactly as in lesson 04.
The workhorse pattern for all three is path accumulation: carry an array of the nodes visited so far, both to detect cycles and to report the actual route. Here, “all parts inside assembly 1, with the path that reaches each” over a bill of materials:
WITH RECURSIVE bom AS (
-- anchor: direct children of assembly 1
SELECT dst AS part, ARRAY[1, dst] AS path, 1 AS depth
FROM edges
WHERE src = 1
UNION ALL
-- recursive term: descend, appending each node to the path
SELECT e.dst, b.path || e.dst, b.depth + 1
FROM edges e
JOIN bom b ON e.src = b.part
WHERE NOT e.dst = ANY(b.path) -- cycle/repeat guard
AND b.depth < 30 -- depth seatbelt
)
SELECT part, path, depth FROM bom ORDER BY depth, part;Reachability, shortest-ish paths, and what SQL can express
This pattern answers the common production questions cleanly. Reachability — “is B reachable from A?” — is EXISTS over the walk. Transitive closure — “all nodes reachable from A” — is SELECT DISTINCT over the walk. You can even approximate shortest path by carrying depth (hop count) and taking the minimum per destination, since the working-table loop expands breadth-first by level. What SQL recursion does not give you cheaply is true weighted shortest path (Dijkstra/A*): you can express it, but the engine has no priority queue, so it explores far more paths than a purpose-built algorithm and the cost is brutal on real graphs.
-- Fewest-hops distance from node 1 to every reachable node
WITH RECURSIVE walk AS (
SELECT dst, 1 AS hops, ARRAY[1, dst] AS path FROM edges WHERE src = 1
UNION ALL
SELECT e.dst, w.hops + 1, w.path || e.dst
FROM edges e JOIN walk w ON e.src = w.dst
WHERE NOT e.dst = ANY(w.path)
)
SELECT dst, min(hops) AS shortest_hops
FROM walk GROUP BY dst ORDER BY dst;The wall: path explosion and per-hop joins
Now the senior part — why this gets slow, and the numbers. Two costs compound. First, each iteration is a join of the whole working table against edges; without an index on edges(src), every hop is a sequential scan, and a 6-hop walk over a 10M-edge table is six full scans. Always index the join column (src, or manager_id/parent_id). Second, and worse, is path explosion: in a dense graph the number of distinct paths grows combinatorially with depth, so the working table can balloon to far more rows than there are nodes — you’re enumerating routes, not nodes. A graph with a few thousand nodes but high fan-out can produce millions of intermediate path rows, blow past work_mem (default 4 MB), spill to disk, and turn a conceptually small traversal into a multi-GB sort.
The mitigations, in order: index the join column; cap depth aggressively (most real questions need 3–6 hops, not unbounded); if you only need reachable nodes, deduplicate early with UNION (per-pass dedup) or restructure to track visited nodes instead of full paths so the working table stays bounded by node count, not path count. With those, recursive CTEs comfortably handle org charts, category trees, and BOMs up to roughly low-millions of edges with bounded depth. Together, these three mitigations shift the bottleneck from memory and disk back to network latency — which is where it belongs for a well-indexed hierarchy query. Skip even one of them on a dense graph and the wall reappears quickly.
Past that — millions of edges with deep or unbounded traversal, frequent shortest-path or centrality queries, or graph-shaped as the primary workload — you’ve outgrown the tool. A relational engine joins sets; it has no native adjacency index, no graph-aware operators. Reach for a graph database (Neo4j and its ilk) or Postgres’s pgRouting/Apache AGE extensions, which add real graph algorithms. The production rule: recursive CTEs are perfect for occasional hierarchy and reachability queries over moderate graphs that already live in your relational schema; they are the wrong default for a graph-centric product.
▸Why this works
Why does a relational engine struggle with graphs when the data fits fine in a table? Because the cost model is built for set operations, not pointer-chasing. Following one edge is a join — Postgres has to look up matching rows, which is cheap once but happens at every hop for every frontier row, and the planner can’t see across iterations to optimize the walk as a whole. A native graph engine stores adjacency as a direct pointer from each node to its neighbors, so a hop is an O(1) dereference, not a relational lookup, and traversal algorithms run in the engine instead of being simulated by a self-joining loop. The data being storable as rows doesn’t make the access pattern relational.
In a graph traversal CTE, the accumulated path array serves two purposes at once. Which two?
A recursive traversal over a dense graph explodes to millions of working-table rows though the graph has only thousands of nodes. What's happening and the first mitigation?
Fill in the blank: a native graph engine follows an edge as an O(1) pointer dereference, whereas a recursive CTE follows each edge as a _______ at every hop, which is why deep traversals on large graphs are slow in SQL.
- 01What two jobs does the path array do in a graph traversal CTE?
- 02Why do recursive graph queries get slow, and what are the mitigations?
- 03When should you stop using recursive CTEs and reach for a graph engine?
The recursive engine from lesson 04 turns Postgres into a competent — within limits — graph traversal tool over edges(src, dst) (or manager_id/parent_id). It handles three shapes: trees (one parent, always terminate), DAGs like bills of materials (no cycles but many paths to a node), and general graphs (cycles possible, so cycle protection is mandatory). The canonical pattern is path accumulation — carry an array of visited nodes that simultaneously guards against cycles (WHERE NOT node = ANY(path)) and records the route to each node. With it you answer reachability (EXISTS), transitive closure (SELECT DISTINCT), and fewest-hops distance (min(hops)), since the loop expands breadth-first by level; true weighted shortest path is expressible but expensive because there’s no priority queue. The wall is real and has numbers: each hop is a join (so index the join column or every hop is a full scan), and path explosion in dense graphs grows working-table rows combinatorially, blowing past the 4 MB default work_mem and spilling to disk. Mitigate by indexing, capping depth (real questions want 3–6 hops), and tracking visited nodes instead of full paths so the set stays bounded by node count. Recursive CTEs comfortably serve occasional hierarchy and reachability queries over moderate graphs that already live relationally; for millions of edges, frequent shortest-path work, or a graph-centric product, switch to a graph database or Postgres’s pgRouting/Apache AGE. That judgment — push the wall back, then know when to stop pushing — is the unit’s senior payoff, and it closes the arc from naming a subquery to walking a graph in pure SQL. Now when a graph query explodes past work_mem, you’ll recognize path explosion before you reach for a bigger instance — and you’ll know exactly when to stop pushing SQL and hand the workload to a graph engine.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.