Three self-joins versus a frontier expansion
Run a real systemNeeds Docker, Python with NumPy
Problem
Graph traversal in SQL is a chain of self-joins, one per hop, and each join multiplies the frontier by the average degree. In a power-law graph the frontier reaches most of the graph within a few hops, and a start at a hub begins with an enormous frontier. A compressed sparse row (CSR) layout, offsets plus a flat neighbour array, makes each expansion a slice of an array and lets a visited set drop duplicates as it goes; it is faster per hop, but the frontier grows just the same.
Generate a power-law follower graph with 1 million vertices (preferential attachment) and load it into PostgreSQL as follows(follower_id, followee_id) with an index on follower_id. Run one- to four-hop queries as chained self-joins from a median vertex and from the highest-degree vertex, recording time and the number of paths. Build CSR arrays with NumPy and run a level-synchronous BFS with a visited array to the same depths.
The question. How do frontier and time grow with depth, for SQL joins and for CSR, from a median start and from the hub?
Predict
Frontier size grows roughly as d^k until it saturates near the size of the connected component, so depth 4 or 5 touches most of the graph. CSR is faster per hop than the indexed join, but both curves bend upward at the same depths. The hub start is far slower than the median start: the variance of degree, not the mean, sets the cost.
Run it
1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
three-self-joins-versus-a.py # /// script # requires-python = ">=3.10" # dependencies = ["psycopg[binary]", "numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Three self-joins against a CSR traversal. A power-law follower graph of 1 million vertices (preferential attachment, 5 edges per new vertex) loaded into PostgreSQL as follows(follower_id, followee_id) with an index. One- to four-hop chained self-joins from a median vertex and from the top hub: time and frontier size. Then the same depths as a level-synchronous BFS over CSR arrays in NumPy. Needs the PostgreSQL service from the compose file (port 56002). """ import argparse, json, time import numpy as np, psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:56002/postgres", help="Connection string.") p.add_argument("--n", type=int, default=1_000_000, help="Vertices.") p.add_argument("--m", type=int, default=5, help="Edges per new vertex.") p.add_argument("--depth", type=int, default=4, help="Hops.") p.add_argument("--timeout-s", type=int, default=120, help="Statement timeout for the joins.") p.add_argument("--max-paths", type=float, default=5e7, help="Skip a SQL hop expected to enumerate more paths than this.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(0) targets = np.empty(args.n * args.m * 2, dtype=np.int64); src, dst = [], []; filled = 0 for v in range(args.m, args.n): # preferential attachment: pick ends of existing edges picks = targets[rng.integers(0, filled, args.m)] if filled else np.arange(args.m) src.append(np.full(args.m, v)); dst.append(picks) targets[filled:filled + args.m] = picks; targets[filled + args.m:filled + 2 * args.m] = v; filled += 2 * args.m s, d = np.concatenate(src), np.concatenate(dst) s, d = np.concatenate([s, d]), np.concatenate([d, s]) # both directions: follows and followed-by order = np.argsort(s, kind="stable"); s, d = s[order], d[order] off = np.zeros(args.n + 1, dtype=np.int64); np.add.at(off, s + 1, 1); off = np.cumsum(off); nbrs = d deg = np.diff(off); hub = int(deg.argmax()); median_v = int(np.argsort(deg)[args.n // 2]) c = psycopg.connect(args.dsn, autocommit=True) c.execute("DROP TABLE IF EXISTS follows"); c.execute("CREATE TABLE follows (follower_id int, followee_id int)") with c.cursor().copy("COPY follows FROM STDIN") as cp: for a, b in zip(s.tolist(), d.tolist()): cp.write_row((a, b)) c.execute("CREATE INDEX ON follows (follower_id)"); c.execute("ANALYZE follows"); c.execute(f"SET statement_timeout = '{args.timeout_s}s'") def sql_hops(start, k): joins = " ".join(f"JOIN follows f{i} ON f{i}.follower_id = f{i - 1}.followee_id" for i in range(1, k)) q = f"SELECT count(*), count(DISTINCT f{k - 1}.followee_id) FROM follows f0 {joins} WHERE f0.follower_id = {start}" t = time.perf_counter() try: total, distinct = c.execute(q).fetchone(); return time.perf_counter() - t, total, distinct except psycopg.errors.QueryCanceled: return None, None, None def bfs(start, k): visited = np.zeros(args.n, dtype=bool); visited[start] = True; frontier = np.array([start]); out = [] t = time.perf_counter() for hop in range(k): nb = np.concatenate([nbrs[off[v]:off[v + 1]] for v in frontier.tolist()]) if len(frontier) else np.array([], dtype=np.int64) nb = np.unique(nb[~visited[nb]]); visited[nb] = True; frontier = nb out.append((hop + 1, time.perf_counter() - t, len(frontier), int(visited.sum()))) return out rows = [] for label, v in (("median vertex", median_v), ("top hub", hub)): paths = 1 for hop, el, front, reached in bfs(v, args.depth): if paths * deg.mean() * 2 > args.max_paths: sj, total, distinct = None, None, None # the join would not fit in memory else: sj, total, distinct = sql_hops(v, hop) paths = total if total else paths * deg.mean() * 2 rows.append({"start": label, "hops": hop, "sql_s": sj, "sql_paths": total, "csr_s": el, "frontier": front, "reached": reached}) summary = {"vertices": args.n, "edges": int(len(s) // 2), "hub_degree": int(deg[hub]), "median_degree": int(deg[median_v]), "mean_degree": float(deg.mean())} print(f"{args.n:,} vertices, {summary['edges']:,} edges; degree: median vertex {summary['median_degree']}, hub {summary['hub_degree']:,}") for r in rows: print(f"{r['start']:<14} {r['hops']} hops: self-joins {('%.2f s' % r['sql_s']) if r['sql_s'] is not None else 'skipped':>10} ({r['sql_paths'] or 0:>12,} paths) CSR BFS {r['csr_s']:6.2f} s, new frontier {r['frontier']:>9,}, reached {r['reached']:>9,}") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"hops": rows}}, f)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide the code
three-self-joins-versus-a.compose.yml # PostgreSQL for the three-self-joins-versus-a lab. services: postgres: image: postgres:17.6 command: ["postgres", "-c", "shared_buffers=512MB", "-c", "work_mem=64MB"] environment: POSTGRES_PASSWORD: lab ports: ["56002:5432"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 1s retries: 304Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.
Terminal docker compose -f three-self-joins-versus-a.compose.yml up -d --wait uv run three-self-joins-versus-a.py uv run three-self-joins-versus-a.py --help # every knob docker compose -f three-self-joins-versus-a.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:56002/postgres | Connection string. |
| --n1,000,000 | Vertices. |
| --m5 | Edges per new vertex. |
| --depth4 | Hops. |
| --timeout-s120 | Statement timeout for the joins. |
| --max-paths50,000,000 | Skip a SQL hop expected to enumerate more paths than this. |
Our run
Hops from a median vertex and from the hub
| Start | Hops | Self-joins | Paths | CSR BFS | Vertices reached |
|---|---|---|---|---|---|
| median vertex | 1 | 4.09 ms | 7 | 80 ms | 8 |
| median vertex | 2 | 913 µs | 133 | 80.4 ms | 134 |
| median vertex | 3 | 5.39 ms | 5.77K | 81.7 ms | 5.7K |
| median vertex | 4 | 150 ms | 201K | 113 ms | 150K |
| top hub | 1 | 5.79 ms | 4.76K | 481 µs | 4.73K |
| top hub | 2 | 96.1 ms | 167K | 19.7 ms | 132K |
| top hub | 3 | 5.23 s | 28.6M | 260 ms | 860K |
| top hub | 4 | — | — | 573 ms | 1M |
1,000,000 vertices, 4,999,975 edges; degree: median vertex 7, hub 4,764
median vertex 1 hops: self-joins 0.00 s ( 7 paths) CSR BFS 0.08 s, new frontier 7, reached 8
median vertex 2 hops: self-joins 0.00 s ( 133 paths) CSR BFS 0.08 s, new frontier 126, reached 134
median vertex 3 hops: self-joins 0.01 s ( 5,773 paths) CSR BFS 0.08 s, new frontier 5,561, reached 5,695
median vertex 4 hops: self-joins 0.15 s ( 201,079 paths) CSR BFS 0.11 s, new frontier 144,454, reached 150,149
top hub 1 hops: self-joins 0.01 s ( 4,764 paths) CSR BFS 0.00 s, new frontier 4,733, reached 4,734
top hub 2 hops: self-joins 0.10 s ( 167,346 paths) CSR BFS 0.02 s, new frontier 127,325, reached 132,059
top hub 3 hops: self-joins 5.23 s ( 28,617,209 paths) CSR BFS 0.26 s, new frontier 728,095, reached 860,154
top hub 4 hops: self-joins skipped ( 0 paths) CSR BFS 0.57 s, new frontier 139,846, reached 1,000,000What we found
From the median vertex (degree 7), four hops reached 150K vertices; the self-joins enumerated 201K paths in 150 ms, the CSR traversal took 113 ms. From the hub (degree 4.76K), three hops of self-joins enumerated 28.6M paths in 5.23 s, while CSR reached 860K distinct vertices in 260 ms. The four-hop self-join from the hub was skipped: a first attempt ran the database out of memory and crashed it. CSR reached the whole graph in four hops from the hub (1M vertices).
Read it
Joins enumerate paths; a traversal with a visited set enumerates vertices. On a power-law graph the difference is orders of magnitude by the third hop, and the hub sets the worst case. Cap the depth, deduplicate per hop, or move multi-hop traversals to a structure built for them.