Skip to content
About
Main content

Cross the dynamic-programming cliff

Run a real systemNeeds Docker, Python

Problem

A that searches join orders exhaustively with considers every connected subset of the tables, and the number of subsets grows exponentially with the number of tables, faster for some query shapes than others: a chain has few connected subsets, a star (every table joined to one hub) has nearly all of them. Past a threshold PostgreSQL switches to a genetic search () that samples join orders instead: planning time stays bounded, and the plan is no longer guaranteed to be the best, nor necessarily the same from run to run.

Generate a query joining 8, 10, 12, 14 and 16 small tables, in a chain and in a star. Run EXPLAIN and record the planning time. Note where it jumps, for the exhaustive search (geqo = off) and for GEQO. At 12 or more relations, plan the same query 20 times and count the distinct plans produced. The script also varies geqo_seed between plannings, as different sessions with different seeds would.

The question. Where does exhaustive planning time jump, and does GEQO produce more than one plan for the same query?


Predict

For a 16-table chain join, how does exhaustive planning (geqo off) compare with GEQO in planning time?

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.

    cross-the-dynamic-programming-cliff.py57 linesDownload
    View the code
    cross-the-dynamic-programming-cliff.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["psycopg[binary]"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Cross the dynamic-programming cliff.
    
    Joins 8 to 16 small PostgreSQL tables in a chain and in a star and records the planning
    time with the exhaustive (dynamic-programming) search and with the genetic
    optimizer (GEQO, used above geqo_threshold). At 12 or more relations, plans
    the same query 20 times and counts the distinct plans each search produces.
    Needs the PostgreSQL service from the compose file (port 55704).
    """
    import argparse, hashlib, json, re
    import psycopg
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55704/postgres", help="Connection string.")
    p.add_argument("--sizes", default="8,10,12,14,16", help="Numbers of joined tables.")
    p.add_argument("--repeats", type=int, default=20, help="Plans per query for the distinct-plan count.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    c = psycopg.connect(args.dsn, autocommit=True)
    sizes = [int(x) for x in args.sizes.split(",")]
    for i in range(max(sizes)):
        c.execute(f"DROP TABLE IF EXISTS j{i}"); c.execute(f"CREATE TABLE j{i} (id int PRIMARY KEY, nxt int, v int)")
        c.execute(f"INSERT INTO j{i} SELECT g, (g * 7 + {i}) % {100 + i * 13}, g % 10 FROM generate_series(1, {100 + i * 13}) g"); c.execute(f"ANALYZE j{i}")
    def query(n, shape):
        if shape == "chain": joins = " ".join(f"JOIN j{i} ON j{i}.id = j{i - 1}.nxt" for i in range(1, n))
        else: joins = " ".join(f"JOIN j{i} ON j{i}.id = j0.nxt + {i}" for i in range(1, n))      # star: every table joins the hub
        return f"SELECT count(*) FROM j0 {joins} WHERE j0.v = 3"
    def plan(q):
        txt = [r[0] for r in c.execute("EXPLAIN (SUMMARY ON, COSTS OFF) " + q).fetchall()]
        t = float(re.search(r"Planning Time: ([\d.]+)", "\n".join(txt)).group(1))
        shape = hashlib.md5("\n".join(l for l in txt if "Planning Time" not in l).encode()).hexdigest()
        return t, shape
    rows = []
    for shape in ("chain", "star"):
        for n in sizes:
            q = query(n, shape)
            for mode in ("exhaustive (geqo off)", "GEQO, fixed seed", "GEQO, seed varied"):
                c.execute("RESET ALL"); c.execute("SET join_collapse_limit = 100"); c.execute("SET from_collapse_limit = 100")
                if mode.startswith("GEQO"): c.execute("SET geqo = on"); c.execute("SET geqo_threshold = 2")
                else: c.execute("SET geqo = off")
                times, shapes = [], set()
                for r_ in range(args.repeats if n >= 12 else 3):
                    if mode == "GEQO, seed varied": c.execute(f"SET geqo_seed = {r_ / args.repeats}")
                    t, sh = plan(q); times.append(t); shapes.add(sh)
                rows.append({"shape": shape, "tables": n, "search": mode, "planning_ms": sorted(times)[len(times) // 2], "distinct_plans": len(shapes) if n >= 12 else None})
    for r in rows: print(f"{r['shape']:<5} {r['tables']:>2} tables, {r['search']:<22} planning {r['planning_ms']:9.2f} ms" + (f"   distinct plans in {args.repeats}: {r['distinct_plans']}" if r["distinct_plans"] else ""))
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"planning": rows}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    cross-the-dynamic-programming-cliff.compose.yml13 linesDownload
    View the code
    cross-the-dynamic-programming-cliff.compose.yml
    # PostgreSQL for the cross-the-dynamic-programming-cliff lab.
    services:
      postgres:
        image: postgres:17.6
        command: ["postgres", "-c", "shared_buffers=256MB", "-c", "max_parallel_workers_per_gather=0"]
        environment:
          POSTGRES_PASSWORD: lab
        ports: ["55704: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 cross-the-dynamic-programming-cliff.compose.yml up -d --wait
    uv run cross-the-dynamic-programming-cliff.py
    uv run cross-the-dynamic-programming-cliff.py --help     # every knob
    docker compose -f cross-the-dynamic-programming-cliff.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--dsnpostgresql://postgres:lab@localhost:55704/postgres Connection string.
--sizes8, 10, 12, 14, 16 Numbers of joined tables.
--repeats20 Plans per query for the distinct-plan count.

Our run

Planning time against the number of tablesApple M2 · 8 cores · 8 GB

chain

star

Data table
Planning time against the number of tables
shapeTables joinedseriesPlanning time (ms)
chain8exhaustive (geqo off)0.254
chain8GEQO, fixed seed8.11
chain8GEQO, seed varied6.95
chain10exhaustive (geqo off)0.351
chain10GEQO, fixed seed8.05
chain10GEQO, seed varied8.09
chain12exhaustive (geqo off)0.519
chain12GEQO, fixed seed10.3
chain12GEQO, seed varied10.4
chain14exhaustive (geqo off)0.784
chain14GEQO, fixed seed12.6
chain14GEQO, seed varied12.6
chain16exhaustive (geqo off)1.21
chain16GEQO, fixed seed14.9
chain16GEQO, seed varied14.9
star8exhaustive (geqo off)0.709
star8GEQO, fixed seed5.53
star8GEQO, seed varied5.66
star10exhaustive (geqo off)3.77
star10GEQO, fixed seed7.21
star10GEQO, seed varied7.34
star12exhaustive (geqo off)19.5
star12GEQO, fixed seed9.29
star12GEQO, seed varied9.24
star14exhaustive (geqo off)120
star14GEQO, fixed seed11.2
star14GEQO, seed varied11.2
star16exhaustive (geqo off)1,650
star16GEQO, fixed seed13.2
star16GEQO, seed varied13.2
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

Planning time and distinct plans

Planning time and distinct plans
ShapeTablesSearchPlanning (ms)Distinct plans in 20
chain8exhaustive (geqo off)0.254—
chain8GEQO, fixed seed8.11—
chain8GEQO, seed varied6.95—
chain10exhaustive (geqo off)0.351—
chain10GEQO, fixed seed8.05—
chain10GEQO, seed varied8.09—
chain12exhaustive (geqo off)0.5191
chain12GEQO, fixed seed10.31
chain12GEQO, seed varied10.417
chain14exhaustive (geqo off)0.7841
chain14GEQO, fixed seed12.61
chain14GEQO, seed varied12.620
chain16exhaustive (geqo off)1.211
chain16GEQO, fixed seed14.91
chain16GEQO, seed varied14.920
star8exhaustive (geqo off)0.709—
star8GEQO, fixed seed5.53—
star8GEQO, seed varied5.66—
star10exhaustive (geqo off)3.77—
star10GEQO, fixed seed7.21—
star10GEQO, seed varied7.34—
star12exhaustive (geqo off)19.51
star12GEQO, fixed seed9.291
star12GEQO, seed varied9.2420
star14exhaustive (geqo off)1201
star14GEQO, fixed seed11.21
star14GEQO, seed varied11.220
star16exhaustive (geqo off)1,6501
star16GEQO, fixed seed13.21
star16GEQO, seed varied13.220
Output · uv run cross-the-dynamic-programming-cliff.py
chain  8 tables, exhaustive (geqo off)  planning      0.25 ms
chain  8 tables, GEQO, fixed seed       planning      8.11 ms
chain  8 tables, GEQO, seed varied      planning      6.95 ms
chain 10 tables, exhaustive (geqo off)  planning      0.35 ms
chain 10 tables, GEQO, fixed seed       planning      8.05 ms
chain 10 tables, GEQO, seed varied      planning      8.09 ms
chain 12 tables, exhaustive (geqo off)  planning      0.52 ms   distinct plans in 20: 1
chain 12 tables, GEQO, fixed seed       planning     10.31 ms   distinct plans in 20: 1
chain 12 tables, GEQO, seed varied      planning     10.36 ms   distinct plans in 20: 17
chain 14 tables, exhaustive (geqo off)  planning      0.78 ms   distinct plans in 20: 1
chain 14 tables, GEQO, fixed seed       planning     12.56 ms   distinct plans in 20: 1
chain 14 tables, GEQO, seed varied      planning     12.57 ms   distinct plans in 20: 20
chain 16 tables, exhaustive (geqo off)  planning      1.21 ms   distinct plans in 20: 1
chain 16 tables, GEQO, fixed seed       planning     14.94 ms   distinct plans in 20: 1
chain 16 tables, GEQO, seed varied      planning     14.94 ms   distinct plans in 20: 20
star   8 tables, exhaustive (geqo off)  planning      0.71 ms
star   8 tables, GEQO, fixed seed       planning      5.53 ms
star   8 tables, GEQO, seed varied      planning      5.66 ms
star  10 tables, exhaustive (geqo off)  planning      3.77 ms
star  10 tables, GEQO, fixed seed       planning      7.21 ms
star  10 tables, GEQO, seed varied      planning      7.34 ms
star  12 tables, exhaustive (geqo off)  planning     19.49 ms   distinct plans in 20: 1
star  12 tables, GEQO, fixed seed       planning      9.29 ms   distinct plans in 20: 1
star  12 tables, GEQO, seed varied      planning      9.24 ms   distinct plans in 20: 20
star  14 tables, exhaustive (geqo off)  planning    120.48 ms   distinct plans in 20: 1
star  14 tables, GEQO, fixed seed       planning     11.18 ms   distinct plans in 20: 1
star  14 tables, GEQO, seed varied      planning     11.23 ms   distinct plans in 20: 20
star  16 tables, exhaustive (geqo off)  planning   1646.63 ms   distinct plans in 20: 1
star  16 tables, GEQO, fixed seed       planning     13.25 ms   distinct plans in 20: 1
star  16 tables, GEQO, seed varied      planning     13.25 ms   distinct plans in 20: 20

What we found

The cliff depended on the shape. For a chain, exhaustive planning stayed cheap, 1.21 ms at 16 tables, faster than GEQO. For a star it exploded: 19.5 ms at 12 tables, 120 ms at 14 and 1,650 ms at 16, while GEQO took 13.2 ms. GEQO with its default fixed seed produced 1 plan in 20 runs, not several: the search is random but seeded, so it repeats itself. With the seed varied it produced 20 distinct plans in 20.


Read it

The cost of exhaustive search is set by the number of connected subsets, which is why a chain of 16 tables plans in a millisecond and a star of 16 takes over a second. GEQO caps the cost and gives up two things: the guarantee of the best plan, and plan stability across anything that changes its seed or its input . A fixed seed hides the second until something else changes.