Srikanth Bandaru

Beat the Planner

Race the Postgres query planner, running for real in your tab. It usually wins.

Drag the tables into a join tree and race the plan Postgres picks.

About 10 MB, downloaded only when you press it.

The game needs a desktop browser, because the tables are dragged with a mouse.

Open full page

Real PostgreSQL 18 runs in your browser tab, compiled to WebAssembly by PGlite. You get a query and its tables, drag the tables into a join tree, and race the plan Postgres picked: both plans really run, and a count of the rows each one touched decides who won. Postgres usually wins. On the 110 benchmark queries where its plan finished, 1,965 of the 2,280 trees a player could draw (86%) did at least as much work as Postgres’s plan or didn’t finish. The levels are the exceptions, found by measuring every possible plan offline.

The trick that makes your tree run as drawn is one setting, join_collapse_limit = 1, which Christophe Pettus explains in The Build and Cybertec’s “Forcing a join order in PostgreSQL” covers. The queries and data are the STATS-CEB benchmark’s, and the failure modes are the subject of the Join Order Benchmark papers (Leis et al. 2015, and its 2025 retrospective). The format, playing one component of a system against it, is The Deadlock Empire’s.

How the levels were found

Nobody designed these levels. A forge ran every tree a player can draw, for each of 111 STATS-CEB queries, with and without a ban on nested loops, on one worker, under exactly the statistics the game restores in your tab. The rule for picking levels was committed before the forge ran: a level needs a gap between the best tree and Postgres, every run has to return the benchmark’s true count, and work and time have to agree on who won for every tree.

The brief named 7 candidates from the spike. Four survived:

Level Query Postgres’s plan (forge) Trees that beat it Postgres’s worst estimate
1: Didn’t finish q134 didn’t finish in 8 s (14.6 s with no deadline) 24 of 24 top join: est 66,902,688,802, true 16,698
2: The ramp q106 9,588,822 rows, 925 ms 6 of 6 Merge Join over comments, postHistory, votes, users: est 358,395,278, true 569,476 (629×)
3: The minefield q50 3,445,405 rows, 445 ms 4 of 14 Merge Join over comments, posts, votes, badges, users: est 672,747,793, true 9,812 (68,557×)
4: The minefield q33 1,192,478 rows, 180 ms 1 of 5 Nested Loop over comments, postHistory, votes, users: est 3,789,175, true 6,299 (601×)

Three didn’t, and the reasons are the interesting part. q57’s Postgres plan, a “didn’t finish” in the spike, finished in 7.4 s on one uncontended worker. q48, the planned level where banning nested loops rescues Postgres, became a level Postgres wins outright: this build’s sample of statistics gives Postgres a different plan, 259,370 rows of work in 57 ms, better than every tree. And q132, the brief’s “real puzzle”, has a tree that does less work than Postgres but takes longer, which is exactly the kind of result that would make players call the referee rigged, so the rule drops it. All three are still playable in random mode.

How it works

  • Postgres in a Worker. The page downloads PGlite’s WebAssembly and eight gzipped CSV tables (about 17 MB before the first race), decompresses them in the tab, builds the tables with COPY and the benchmark’s indexes, and restores the planner statistics the forge measured with PostgreSQL 18’s pg_restore_relation_stats and pg_restore_attribute_stats. It never runs ANALYZE, because ANALYZE samples at random and every tab would plan differently. A hash of each plan is checked against the forge’s after loading; on the build machine, restored statistics reproduced the original plans for all 224 plans compared.
  • A referee that doesn’t use a stopwatch. Work is the sum over a plan’s steps of the rows each step produced or threw away, times how often it ran, from EXPLAIN (ANALYZE, TIMING OFF). The same plan on the same data gives the same count on any machine. Time is shown with ≈.
  • Didn’t finish. Every run gets 8 seconds. statement_timeout doesn’t fire inside PGlite, so the page terminates the Worker and starts a new one from the files it already has. In headless Chromium the stopped query kept a core busy for about 2 seconds after terminate(); in Firefox and WebKit it stopped within 0.2 seconds.
  • The autopsy. When Postgres’s plan never finishes, the reveal shows each join’s estimate beside its true size, counted separately by the forge. On Level 1, Postgres expected 66,902,688,802 rows at its top join; there are 16,698.

Numbers

Measured on the cloud machine this was built on (a 4-core Xeon, headless browsers), not a desktop. With the boot path the game ships, Level 1 was ready to race in 2.9 s in Chromium, 3.5 s in Firefox and 3.0 s in WebKit, the median of 5 cold loads each, served locally. Laptop and Safari numbers are still to be recorded. Every number in the game says where it came from: this tab, the forge, the spike, or derived.