Skip to content
About
Main content

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 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 () 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

Starting from a median vertex, how many of the million vertices do four hops reach?

Run it

  1. 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 | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    three-self-joins-versus-a.py74 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    three-self-joins-versus-a.compose.yml13 linesDownload
    View 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: 30
    
  4. 4Start 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
Knobs
Knob · defaultWhat 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

Hops from a median vertex and from the hub
StartHopsSelf-joinsPathsCSR BFSVertices reached
median vertex14.09 ms780 ms8
median vertex2913 µs13380.4 ms134
median vertex35.39 ms5.77K81.7 ms5.7K
median vertex4150 ms201K113 ms150K
top hub15.79 ms4.76K481 µs4.73K
top hub296.1 ms167K19.7 ms132K
top hub35.23 s28.6M260 ms860K
top hub4——573 ms1M
Reference runApple M2 · 8 cores · 8 GB
Output · uv run three-self-joins-versus-a.py
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,000
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · numpy 2.5.4 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

What 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.