ExoBench Now Runs in Your Browser

No install, no setup. Paste a slow query at exobench.ai and watch it spin up a real Postgres at 100K, 1M, and 3M row and find a good index.

Summary for the Impatient

ExoBench now runs as a chatbot at exobench.ai — no install, no setup. Paste a slow SQL query and an AI agent calls ExoBench, which spins up a real PostgreSQL database at multiple scales (100K, 1M, 3M rows), runs EXPLAIN ANALYZE, and tries indexes until the plan stops improving. The lesson: query plans change with scale, so the "obvious" index can win at small scale and lose at large scale — in the demo, a partial index sped the query up at 100K rows but made it slower at 3M, while a covering index stayed 4–7x faster everywhere. A naive LLM guesses an index from the SQL; ExoBench measures it at production scale.

Nobody Follows the Query Planner Plot

There's a Christopher Nolan movie rule: nobody really follows the plot. You nod along for the whole film and agree that some kind of ineffable intelligence strung together the scenes. Databases run on the same rule.

Nobody really understands how a query plan scales. We poke it with indexes until it gets fast.

Say it at a standup and watch every senior engineer quietly nod. The planner is a box that changes its mind as your tables grow and the workflow is: add an index, run it, check if the number dropped, and repeat until you get something working. The problem is that you cannot poke it at production scale so you play around with it on your laptop, double check it doesn't break in staging and quietly ship it, hoping and praying that production data lives in the same reality.

ExoBench does the poking, the scale, and even understands the plot well enough to give you back the right index. It now runs as a chatbot at exobench.ai. You paste in a slow query, an AI agent calls ExoBench, and ExoBench spins up a real database, multiple scales! At each scale, it runs EXPLAIN ANALYZE and tries one index after another until the plan stops improving. You watch, or you don't. The numbers below are the default demo, on PostgreSQL across 100K, 1M, and 3M rows.

The query is the kind we all have shipped:

SELECT id, customer_name, total, created_at
FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC;

A pending-orders list, newest first. It looks innocent but gets slower as the table grows in a different way at each size. Nobody with sprint deliverables is going to track this in their head.

The Baseline is Three Plans

The agent measures the query as written, primary key index only, across three scales at once. Here's what shows up in the chatbot:

Benchmark SQL#1 of 3
benchmarkSql complete
MCP Tool
postgres21.25s computewarm ×5
EXPLAIN ANALYZE SELECT id, customer_name, total, created_at FROM orders WHERE status = 'pending' ORDER BY created_at DESC;
CREATE TABLE orders (
  id            bigserial PRIMARY KEY,
  user_id       bigint NOT NULL,
  customer_name text NOT NULL,
  status        text NOT NULL,
  total         numeric(10,2) NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now()
);

INSERT INTO orders (user_id, customer_name, status, total, created_at)
SELECT
  (random()*100000)::bigint + 1,
  'cust_' || (random()*100000)::int,
  CASE
    WHEN random() < 0.10 THEN 'pending'
    WHEN random() < 0.55 THEN 'completed'
    WHEN random() < 0.80 THEN 'shipped'
    ELSE 'cancelled'
  END,
  (random()*1000)::numeric(10,2),
  NOW() - (random() * 365 * interval '1 day')
FROM generate_series(1, ${SCALE}) i;

VACUUM ANALYZE;
ScaleTimePlanCompute
100K10.2 ms
Baseline with no index on status or created_at. PostgreSQL runs a Seq Scan on orders, throws away 90,095 rows in the Filter, then quicksorts 9,905 surviving rows by created_at DESC in 925kB of work_mem. Execution Time 10.152 ms at 100,000 rows.
QUERY PLAN
Sort  (cost=2949.29..2974.42 rows=10053 width=32) (actual time=9.091..9.809 rows=9905 loops=1)
  Sort Key: created_at DESC
  Sort Method: quicksort  Memory: 925kB
  ->  Seq Scan on orders  (cost=0.00..2281.00 rows=10053 width=32) (actual time=0.010..7.183 rows=9905 loops=1)
        Filter: (status = 'pending'::text)
        Rows Removed by Filter: 90095
Planning Time: 0.061 ms
Execution Time: 10.152 ms
3.09s
1M62.9 ms
At 1,000,000 rows the unindexed ORDER BY goes parallel: Gather Merge over 2 launched workers, each running a Parallel Seq Scan that removes 300,010 rows by filter and a quicksort in about 3MB. Execution Time 62.944 ms.
QUERY PLAN
Gather Merge  (cost=19889.64..30095.42 rows=87472 width=32) (actual time=37.281..59.169 rows=99971 loops=1)
  Workers Planned: 2
  Workers Launched: 2
  ->  Sort  (cost=18889.62..18998.96 rows=43736 width=32) (actual time=34.695..38.876 rows=33324 loops=3)
        Sort Key: created_at DESC
        Sort Method: quicksort  Memory: 3287kB
        Worker 0:  Sort Method: quicksort  Memory: 2512kB
        Worker 1:  Sort Method: quicksort  Memory: 2420kB
        ->  Parallel Seq Scan on orders  (cost=0.00..15518.33 rows=43736 width=32) (actual time=0.009..26.553 rows=33324 loops=3)
              Filter: (status = 'pending'::text)
              Rows Removed by Filter: 300010
Planning Time: 0.086 ms
Execution Time: 62.944 ms
4.83s
3M185.3 ms
At 3,000,000 rows the per-worker sort no longer fits in work_mem, so quicksort becomes Sort Method external merge, spilling 5,032kB to disk under a 2-worker Gather Merge. Execution Time 185.338 ms, 18x the 100,000-row number.
QUERY PLAN
Gather Merge  (cost=61195.32..90500.07 rows=251166 width=32) (actual time=114.061..173.780 rows=300858 loops=1)
  Workers Planned: 2
  Workers Launched: 2
  ->  Sort  (cost=60195.30..60509.26 rows=125583 width=32) (actual time=110.212..121.060 rows=100286 loops=3)
        Sort Key: created_at DESC
        Sort Method: external merge  Disk: 5032kB
        Worker 0:  Sort Method: external merge  Disk: 4816kB
        Worker 1:  Sort Method: external merge  Disk: 4856kB
        ->  Parallel Seq Scan on orders  (cost=0.00..46553.00 rows=125583 width=32) (actual time=0.016..76.795 rows=100286 loops=3)
              Filter: (status = 'pending'::text)
              Rows Removed by Filter: 899714
Planning Time: 0.066 ms
Execution Time: 185.338 ms
13.34s

Here comes the plan-reading. You can skim it. Seriously, skim it, I'll meet you at the number.

At 100K it's a sequential scan and an in-memory sort. At 1M Postgres decides to go parallel, because sure, why not? At 3M it stays parallel and the sort runs out of memory and spills to disk, which is the database sighing heavily. Three plans, one query. The planner changes its mind twice without telling you why.

Follow all of that? Doesn't matter. Here's the number: 10.2 ms, then 62.9 ms, then 185.3 ms. The line goes the wrong way. An LLM reading the SQL sees one query and one plan. ExoBench saw three, because it ran three.

If you want the why (nerds, welcome): ten percent of the rows are pending, the scan reads all of them anyway, and the sort is the expensive part. The fix keeps just the pending rows in date order. If you don't want the why: an index goes here. Onward.

The Obvious Index traps at Scale

The agent adds the index any of us would add: a partial index. The one you'd swear by.

CREATE INDEX idx_pending_created
ON orders (created_at DESC)
WHERE status = 'pending';

Only here's what happens:

Benchmark SQL#2 of 3
benchmarkSql complete
MCP Tool
postgres19.26s computewarm ×5
EXPLAIN ANALYZE SELECT id, customer_name, total, created_at FROM orders WHERE status = 'pending' ORDER BY created_at DESC;
CREATE TABLE orders (
  id            bigserial PRIMARY KEY,
  user_id       bigint NOT NULL,
  customer_name text NOT NULL,
  status        text NOT NULL,
  total         numeric(10,2) NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now()
);

INSERT INTO orders (user_id, customer_name, status, total, created_at)
SELECT
  (random()*100000)::bigint + 1,
  'cust_' || (random()*100000)::int,
  CASE
    WHEN random() < 0.10 THEN 'pending'
    WHEN random() < 0.55 THEN 'completed'
    WHEN random() < 0.80 THEN 'shipped'
    ELSE 'cancelled'
  END,
  (random()*1000)::numeric(10,2),
  NOW() - (random() * 365 * interval '1 day')
FROM generate_series(1, ${SCALE}) i;

CREATE INDEX idx_pending_created ON orders (created_at DESC) WHERE status = 'pending';
VACUUM ANALYZE;
ScaleTimePlanCompute
100K6.7 ms
Partial index idx_pending_created on (created_at DESC) WHERE status = 'pending'. At 100,000 rows the planner picks Bitmap Index Scan plus Bitmap Heap Scan over 1,031 heap blocks, still followed by a 935kB quicksort. Execution Time 6.677 ms against 10.152 ms for the Seq Scan baseline.
QUERY PLAN
Sort  (cost=1981.93..2006.63 rows=9877 width=32) (actual time=5.445..6.292 rows=10102 loops=1)
  Sort Key: created_at DESC
  Sort Method: quicksort  Memory: 935kB
  ->  Bitmap Heap Scan on orders  (cost=172.14..1326.60 rows=9877 width=32) (actual time=0.469..3.404 rows=10102 loops=1)
        Recheck Cond: (status = 'pending'::text)
        Heap Blocks: exact=1031
        ->  Bitmap Index Scan on idx_pending_created  (cost=0.00..169.67 rows=9877 width=0) (actual time=0.372..0.372 rows=10102 loops=1)
Planning Time: 0.083 ms
Execution Time: 6.677 ms
0.91s
1M83 ms
The trap: a bitmap scan returns rows in heap order, not index order, so ORDER BY created_at DESC still needs a Sort. At 1,000,000 rows that sort spills, Sort Method external merge Disk 4,880kB over 10,309 heap blocks. Execution Time 82.992 ms, slower than the 62.944 ms baseline.
QUERY PLAN
Sort  (cost=23783.79..24031.87 rows=99233 width=32) (actual time=68.744..78.463 rows=99817 loops=1)
  Sort Key: created_at DESC
  Sort Method: external merge  Disk: 4880kB
  ->  Bitmap Heap Scan on orders  (cost=1621.27..13171.68 rows=99233 width=32) (actual time=6.435..34.971 rows=99817 loops=1)
        Recheck Cond: (status = 'pending'::text)
        Heap Blocks: exact=10309
        ->  Bitmap Index Scan on idx_pending_created  (cost=0.00..1596.46 rows=99233 width=0) (actual time=5.245..5.246 rows=99817 loops=1)
Planning Time: 0.086 ms
Execution Time: 82.992 ms
4.76s
3M245 ms
At 3,000,000 rows the partial index costs 244.986 ms against 185.338 ms for having no index at all. Bitmap Heap Scan touches 30,927 heap blocks, the sort spills 14,656kB to disk, and the single-process bitmap plan gives up the 2 parallel workers the Seq Scan plan used.
QUERY PLAN
Sort  (cost=72449.80..73171.55 rows=288700 width=32) (actual time=195.434..232.272 rows=300203 loops=1)
  Sort Key: created_at DESC
  Sort Method: external merge  Disk: 14656kB
  ->  Bitmap Heap Scan on orders  (cost=4820.10..39356.85 rows=288700 width=32) (actual time=20.224..104.989 rows=300203 loops=1)
        Recheck Cond: (status = 'pending'::text)
        Heap Blocks: exact=30927
        ->  Bitmap Index Scan on idx_pending_created  (cost=0.00..4747.92 rows=288700 width=0) (actual time=15.301..15.301 rows=300203 loops=1)
Planning Time: 0.079 ms
Execution Time: 244.986 ms
13.59s

At 100K it worked. 10.2 down to 6.7. If your staging box holds a hundred thousand rows, you ship this, close the ticket, and call it a day.

At 3M it made things worse. 245.0 ms, slower than the 185.3 ms you started with. The obvious index, the one we were all so sure about, loses to doing nothing.

Cool. Cool cool cool.

The skippable reason: the planner picked a Bitmap Index Scan, which throws away the ordering the index existed to provide, so it sorts anyway and spills anyway. The unskippable reason it matters: you would have shipped this. It looked great at staging-scale. Nobody sees the flip coming, because the planner is ineffable... except for ExoBench.

Presto! The plan goes Flat

Now here's were ExoBench starts earning its keep. It feeds real stats back to your agent, the agent shrugs and keeps thinking. Next thing it tries is making the index covering, so the database never touches the table.

CREATE INDEX idx_pending_cov
ON orders (created_at DESC)
INCLUDE (id, customer_name, total)
WHERE status = 'pending';

Here's what happens:

Benchmark SQL#3 of 3
benchmarkSql complete
MCP Tool
postgres17.84s computewarm ×5
EXPLAIN ANALYZE SELECT id, customer_name, total, created_at FROM orders WHERE status = 'pending' ORDER BY created_at DESC;
CREATE TABLE orders (
  id            bigserial PRIMARY KEY,
  user_id       bigint NOT NULL,
  customer_name text NOT NULL,
  status        text NOT NULL,
  total         numeric(10,2) NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now()
);

INSERT INTO orders (user_id, customer_name, status, total, created_at)
SELECT
  (random()*100000)::bigint + 1,
  'cust_' || (random()*100000)::int,
  CASE
    WHEN random() < 0.10 THEN 'pending'
    WHEN random() < 0.55 THEN 'completed'
    WHEN random() < 0.80 THEN 'shipped'
    ELSE 'cancelled'
  END,
  (random()*1000)::numeric(10,2),
  NOW() - (random() * 365 * interval '1 day')
FROM generate_series(1, ${SCALE}) i;

CREATE INDEX idx_pending_cov ON orders (created_at DESC) INCLUDE (id, customer_name, total) WHERE status = 'pending';
VACUUM ANALYZE;
ScaleTimePlanCompute
100K1.5 ms
Covering partial index idx_pending_cov on (created_at DESC) INCLUDE (id, customer_name, total) WHERE status = 'pending'. The plan collapses to a single Index Only Scan with Heap Fetches 0 and no Sort node at all. Execution Time 1.483 ms at 100,000 rows.
QUERY PLAN
Index Only Scan using idx_pending_cov on orders  (cost=0.29..440.34 rows=9870 width=32) (actual time=0.009..1.148 rows=9996 loops=1)
  Heap Fetches: 0
Planning Time: 0.032 ms
Execution Time: 1.483 ms
0.77s
1M14.9 ms
Index Only Scan with Heap Fetches 0 at 1,000,000 rows, Execution Time 14.864 ms. The index already stores the rows in created_at DESC order and carries every selected column, so PostgreSQL skips both the heap and the sort.
QUERY PLAN
Index Only Scan using idx_pending_cov on orders  (cost=0.42..4308.42 rows=96267 width=32) (actual time=0.020..11.518 rows=100196 loops=1)
  Heap Fetches: 0
Planning Time: 0.068 ms
Execution Time: 14.864 ms
4.33s
3M44.8 ms
At 3,000,000 rows the covering index holds 44.760 ms against 185.338 ms with no index and 244.986 ms with the plain partial index. Still one Index Only Scan, Heap Fetches 0, no external merge, no parallel workers needed.
QUERY PLAN
Index Only Scan using idx_pending_cov on orders  (cost=0.42..13240.92 rows=312300 width=32) (actual time=0.023..34.749 rows=300067 loops=1)
  Heap Fetches: 0
Planning Time: 0.068 ms
Execution Time: 44.760 ms
12.74s

Index Only Scan at every scale. 1.5 ms, 14.9, 44.8. Heap Fetches: 0, no sort, no spill, the same flat green plan whether you hold a hundred thousand rows or three million.

Why does covering the index fix it? Skim away: the database walks the index in order and reads the selected columns straight out of it, so nothing is left to sort and nothing is left to fetch. Or the version you came for: it's fast now. You're done.

The Plot Synopsis

One query. Three index candidates. Three scales. Nine benchmarks. Here's the entire movie, and you can nod along.

One query, three index candidates, three scales. The obvious partial index is the trap; the covering index is the flat line.
Baseline (PK only): 10.2ms at 100K, 62.9ms at 1M, 185.3ms at 3M. Plain partial index: 6.7ms at 100K, 83.0ms at 1M, 245.0ms at 3M — slower than baseline at 1M and 3M. Covering partial index: 1.5ms at 100K, 14.9ms at 1M, 44.8ms at 3M — flat and fastest at every scale.
Baseline climbs from 10.2ms to 185.3ms. The obvious partial index is WORSE at scale (83ms, 245ms). The covering index stays flat and fast (1.5ms, 14.9ms, 44.8ms).
Baseline (PK only): 10.2ms at 100K, 62.9ms at 1M, 185.3ms at 3M. Plain partial index: 6.7ms at 100K, 83.0ms at 1M, 245.0ms at 3M — slower than baseline at 1M and 3M. Covering partial index: 1.5ms at 100K, 14.9ms at 1M, 44.8ms at 3M — flat and fastest at every scale.

The final results, with the speedup over baseline:

ScaleBaseline (PK only)Partial indexCovering index
100K10.2 ms6.7 ms1.5 ms (6.8x)
1M62.9 ms83.0 ms14.9 ms (4.2x)
3M185.3 ms245.0 ms44.8 ms (4.1x)

The winning index, no change to your query:

CREATE INDEX idx_pending_cov
ON orders (created_at DESC)
INCLUDE (id, customer_name, total)
WHERE status = 'pending';

The LLM gets the Plot Wrong

A naive LLM would read your SQL and recommend an index with total confidence and it would recommend the partial one, the obvious one. It's the same one I'd have grabbed, and stopped there. It's the guy at your party explaining the Nolan plot in full detail getting it completely backwards because he only knows anything about it from a reddit post.

ExoBench watched the movie three times over, watched the Bitmap Index Scan drop the ordering, watched the sort spill, found the trap, and landed on the index whose plan stays flat.

An LLM guesses. ExoBench measures.

The Limits (read this one)

Everything else here is skippable. The limits are not, because here's where I tell you what ExoBench won't do.

The data is synthetic. ExoBench generated the pending/shipped/delivered split the demo describes, and a benchmark is only as honest as the distribution you give it. The agent guesses your schema when you don't hand it one, and it guesses wrong sometimes, so double check what it used! It benchmarks the query you give it, so a fast version of a bad query is still a bad query. Two runs can take different routes and both be fine. ExoBench leaves your infrastructure alone, so connection pools, memory pressure, and network latency stay your problem. The instances cap at a handful of scales and a few million rows each.

You still have to verify before you ship. The difference is you now have a number at production scale to verify against, in minutes, while sipping coffee.

Run your slowest query

Postgres users, your candidate is one query away:

SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;

Top row is the one. Go to exobench.ai, watch the demo poke this query until it's fast, then paste your own into the same box. Sign in with GitHub to run yours, one click, the same identity the connector uses. No install, no setup.

You don't have to understand how your query plan scales. ExoBench makes it fast. You can skim the rest.