Cross the dynamic-programming cliff
Run a real systemNeeds Docker, Python
Problem
A planner that searches join orders exhaustively with dynamic programming 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 (GEQO) 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
Expect more than one distinct plan with GEQO, and exactly one with SET geqo = off (at the price of longer planning time).
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
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)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide 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: 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 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
| Knob · default | What 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
chain
star
Data table
| shape | Tables joined | series | Planning time (ms) |
|---|---|---|---|
| chain | 8 | exhaustive (geqo off) | 0.254 |
| chain | 8 | GEQO, fixed seed | 8.11 |
| chain | 8 | GEQO, seed varied | 6.95 |
| chain | 10 | exhaustive (geqo off) | 0.351 |
| chain | 10 | GEQO, fixed seed | 8.05 |
| chain | 10 | GEQO, seed varied | 8.09 |
| chain | 12 | exhaustive (geqo off) | 0.519 |
| chain | 12 | GEQO, fixed seed | 10.3 |
| chain | 12 | GEQO, seed varied | 10.4 |
| chain | 14 | exhaustive (geqo off) | 0.784 |
| chain | 14 | GEQO, fixed seed | 12.6 |
| chain | 14 | GEQO, seed varied | 12.6 |
| chain | 16 | exhaustive (geqo off) | 1.21 |
| chain | 16 | GEQO, fixed seed | 14.9 |
| chain | 16 | GEQO, seed varied | 14.9 |
| star | 8 | exhaustive (geqo off) | 0.709 |
| star | 8 | GEQO, fixed seed | 5.53 |
| star | 8 | GEQO, seed varied | 5.66 |
| star | 10 | exhaustive (geqo off) | 3.77 |
| star | 10 | GEQO, fixed seed | 7.21 |
| star | 10 | GEQO, seed varied | 7.34 |
| star | 12 | exhaustive (geqo off) | 19.5 |
| star | 12 | GEQO, fixed seed | 9.29 |
| star | 12 | GEQO, seed varied | 9.24 |
| star | 14 | exhaustive (geqo off) | 120 |
| star | 14 | GEQO, fixed seed | 11.2 |
| star | 14 | GEQO, seed varied | 11.2 |
| star | 16 | exhaustive (geqo off) | 1,650 |
| star | 16 | GEQO, fixed seed | 13.2 |
| star | 16 | GEQO, seed varied | 13.2 |
Planning time and distinct plans
| Shape | Tables | Search | Planning (ms) | Distinct plans in 20 |
|---|---|---|---|---|
| chain | 8 | exhaustive (geqo off) | 0.254 | — |
| chain | 8 | GEQO, fixed seed | 8.11 | — |
| chain | 8 | GEQO, seed varied | 6.95 | — |
| chain | 10 | exhaustive (geqo off) | 0.351 | — |
| chain | 10 | GEQO, fixed seed | 8.05 | — |
| chain | 10 | GEQO, seed varied | 8.09 | — |
| chain | 12 | exhaustive (geqo off) | 0.519 | 1 |
| chain | 12 | GEQO, fixed seed | 10.3 | 1 |
| chain | 12 | GEQO, seed varied | 10.4 | 17 |
| chain | 14 | exhaustive (geqo off) | 0.784 | 1 |
| chain | 14 | GEQO, fixed seed | 12.6 | 1 |
| chain | 14 | GEQO, seed varied | 12.6 | 20 |
| chain | 16 | exhaustive (geqo off) | 1.21 | 1 |
| chain | 16 | GEQO, fixed seed | 14.9 | 1 |
| chain | 16 | GEQO, seed varied | 14.9 | 20 |
| star | 8 | exhaustive (geqo off) | 0.709 | — |
| star | 8 | GEQO, fixed seed | 5.53 | — |
| star | 8 | GEQO, seed varied | 5.66 | — |
| star | 10 | exhaustive (geqo off) | 3.77 | — |
| star | 10 | GEQO, fixed seed | 7.21 | — |
| star | 10 | GEQO, seed varied | 7.34 | — |
| star | 12 | exhaustive (geqo off) | 19.5 | 1 |
| star | 12 | GEQO, fixed seed | 9.29 | 1 |
| star | 12 | GEQO, seed varied | 9.24 | 20 |
| star | 14 | exhaustive (geqo off) | 120 | 1 |
| star | 14 | GEQO, fixed seed | 11.2 | 1 |
| star | 14 | GEQO, seed varied | 11.2 | 20 |
| star | 16 | exhaustive (geqo off) | 1,650 | 1 |
| star | 16 | GEQO, fixed seed | 13.2 | 1 |
| star | 16 | GEQO, seed varied | 13.2 | 20 |
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: 20What 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 statistics. A fixed seed hides the second until something else changes.