hzerrad Logo

hzerrad@insights:~$cat posts/softeng/a-4b-parameter-model-beat-postgresqls-planner-first-ask-why-your-plan-is-bad.md

A 4B-parameter model beat PostgreSQL's planner. First ask why your plan is bad

A 4B-parameter model just reported a 1.81× speedup over PostgreSQL's planner. Before that becomes a reason to put a learned optimizer in front of your database, check whether your bad plan was a search failure at all. In the incident behind this post, the planner expected 1,700 rows and got 16 million. It picked a nested loop, and for 1,700 rows that was the right call. Smarter search cannot fix a plan built on the wrong number. Two checks to run first, and why the fix was `ANALYZE`, not another optimizer.

HZ
Houssem Eddine Zerrad
9 min read
On this page
  1. Search and estimation are different axes
  2. The case where it wasn't
  3. The check I run first
  4. Fix the information before fixing the search
  5. Where smarter search really does help
  6. Runtime feedback blurs the boundary
  7. There is a third axis too
  8. The optimizer may be innocent
  9. References

A recent experiment trained a 4-billion-parameter model to find faster PostgreSQL execution plans and reported a 1.81× geometric-mean speedup over stock PostgreSQL on the Join Order Benchmark. That is a real result, and it is easy to draw the wrong operational conclusion from it.

A bad plan does not necessarily mean PostgreSQL searched the plan space badly. Sometimes the planner chose exactly the plan it should have chosen given what it believed about the data, and the failure happened one step earlier: it was wrong about how much data existed. Those are different bugs. Before I evaluate a learned optimizer, a hinting extension, or any other layer on top of the planner, I want to know which of the two I actually have.

Search and estimation are different axes

PostgreSQL does not go from SQL text to a plan in one move. It estimates first. How many rows survive this predicate? How many will this join produce? How expensive is each operator at those row counts? Only then does it search over possible execution strategies and compare their estimated costs.

So there are at least two places a bad plan can come from:

Bad query plan Bad estimation Bad search wrong cardinalitiesstale or missing statscorrelation missedskew not represented reasonable estimates,but a poor plan selectedfrom the search space
Diagram source
flowchart TD
    A[Bad query plan] --> B[Bad estimation]
    A --> C[Bad search]
    B --> B1["wrong cardinalities<br/>stale or missing stats<br/>correlation missed<br/>skew not represented"]
    C --> C1["reasonable estimates,<br/>but a poor plan selected<br/>from the search space"]

The second branch is real, which is exactly why learned optimizers are interesting. Join-order search goes combinatorial fast. PostgreSQL cannot enumerate every ordering for an arbitrarily wide join, and above geqo_threshold it hands the problem to the Genetic Query Optimizer, which samples the space instead of walking it. That trade-off is deliberate. Search costs time, and sometimes a much faster plan sits unexplored.

The QORL experiment makes that limitation visible. Its model does not just read a query and emit a plan. It behaves more like a search agent: it inspects the schema and PostgreSQL's own plan, proposes alternatives, executes them, watches how they perform, and tries again. It can change join order, scan choices, parallelism, planner settings, and even supply explicit row-count corrections. That makes the result more interesting, not less. There are workloads where spending more intelligence on search finds plans PostgreSQL misses.

But before I put something like that in front of a production database, I have a prior question. Was search the thing that failed?

The case where it wasn't

I recently wrote about a query where the planner expected roughly 1,700 rows at an important node and execution delivered roughly 16 million. The plan had a nested loop in it.

On first read that looks like a planner failure. Sixteen million rows through a nested loop is the kind of thing that makes people start distrusting the optimizer and reaching for something smarter: a hint, enable_nestloop = off, one of the learned planners advertised as beating PostgreSQL on join ordering.

Except PostgreSQL never believed it had 16 million rows. It believed it had 1,700, and for 1,700 rows a nested loop is a perfectly defensible choice. The bad plan was downstream of a bad number.

estimated rows:       ~1,700
actual rows:      ~16,000,000

When the estimate is off by four orders of magnitude, I am not ready to call it a search problem. The planner was optimizing a different world from the one execution eventually handed it. A smarter search might still stumble onto a plan that degrades more gracefully under that mistake, and a system with runtime feedback might learn around it after watching it go wrong a few times. But the first question stands: why was the optimizer reasoning about 1,700 rows when 16 million were on the way?

The full investigation, including the attempts that changed the plan's shape without fixing the information behind it, is in Postgres Sharding Doesn't Fix a Bad Cardinality Estimate.

The check I run first

Two tools, both already installed on most production PostgreSQL deployments.

pg_stat_statements tells me which query matters. It answers a different question from EXPLAIN: not "why is this slow" but "which one is actually consuming the database's time". Without it, it is easy to spend a week on a spectacularly slow query that runs once a night while a merely slow one runs ten thousand times a minute untouched.

sql
SELECT query,
       calls,
       total_exec_time,
       rows,
       total_exec_time / calls AS avg_ms
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

Then, on the query that matters:

sql
EXPLAIN (ANALYZE, BUFFERS)

Now I can put what PostgreSQL predicted next to what happened.

-> Nested Loop  (cost=0.43..912.10 rows=1700 width=48)
                (actual time=0.031..48213.902 rows=16043211 loops=1)
   Buffers: shared hit=1204 read=891234

Before I look at the cost, the join type, or the scan strategy, I look at rows=1700 against rows=16043211. That one line is most of the diagnosis. Everything downstream of that node was chosen for a dataset four orders of magnitude smaller than the one that showed up. So the question changes. Not "why did PostgreSQL pick a nested loop?" but "why did PostgreSQL think 1,700 rows would reach this node?"

I would want this run before anyone opens a ticket titled "evaluate learned query planner".

Two things people reach for first. Both leave the estimate alone.

The first is forcing the join strategy, through a hint extension or by disabling nested loops for the session. A hash join degrades far more gracefully at 16 million rows, so the bad case gets better. But enable_nestloop = off takes nested loops away from every query in that session, including the ones where 1,700 really is the count and a nested loop was the right call. You have patched the plan shape and left the number that produced it in place.

The second is the model in the title. Feed a learned planner the same statistics the stock planner had and it will plausibly find a plan that beats PostgreSQL on predicted cost. That is what it is trained to do. The plan is still built on the 1,700-row estimate. Any operator sized from that number, the batch count of a hash join for instance, gets set up for 1,700 rows and then handed 16 million. The model did not give a worse answer to the question it was asked. It gave the right answer to the wrong question. Run EXPLAIN ANALYZE on either candidate plan and the same cardinality error is sitting there, wearing a different join.

What fixed the real incident was more boring. We materialized the intermediate result into a temporary table and ran ANALYZE on it before the final step of the workload. That gave the planner statistics about the relation it was actually about to join, instead of an estimate stitched together from upstream selectivities that had nothing to do with the intermediate data. No permanent hint. No global planner setting. No model-serving infrastructure. The stock planner, given a true row count, picked a sensible plan on its own.

ANALYZE is not entirely free. It writes catalog rows, and a workload that creates, truncates, and repopulates temp tables at high volume will see that as pg_class churn and extra autovacuum work on pg_class itself. But it is a command, not a dependency, and it costs nothing to try.

The problem was never that PostgreSQL lacked a cleverer way to arrange the joins. It did not know what it was joining. That is a different class of failure, and a considerably cheaper one to fix.

Where smarter search really does help

Now flip it. Suppose EXPLAIN ANALYZE shows this:

estimated rows:  4,800,000
actual rows:     5,100,000

Not perfect, but the planner clearly understands the scale. And suppose the estimates stay plausible through the expensive part of the plan, yet PostgreSQL still lands on a poor join order, a bad scan choice, or the wrong degree of parallelism. The diagnosis moves. The planner had good information and did not use it well, and the failure is increasingly likely to be in search or costing.

Large divergence Estimates plausible Slow query Estimated vs actual rows? Estimation problem Search / costing problem ANALYZEextended statisticsstatistics targetquery structurematerialization join orderscan selectionplanner cost modelhintslearned optimizer
Diagram source
flowchart TD
    A[Slow query] --> B{Estimated vs actual rows?}
    B -- Large divergence --> C[Estimation problem]
    B -- Estimates plausible --> D[Search / costing problem]
    C --> C1["ANALYZE<br/>extended statistics<br/>statistics target<br/>query structure<br/>materialization"]
    D --> D1["join order<br/>scan selection<br/>planner cost model<br/>hints<br/>learned optimizer"]

This is the branch Neo and Bao were built for: plan selection under decent statistics, in a space too wide to enumerate. Many-way joins and deeply nested subqueries past geqo_threshold can come out mediocre even when every cardinality feeding the planner is right, because the search itself is heuristic. Cost-based optimizers are cheap and stable on simple queries and get shakier as query complexity grows; the hybrid-optimizer literature exists because of that gap.

QORL makes the case sharper because its model can actively try alternatives and measure them. If it keeps finding a substantially better plan on queries where PostgreSQL's estimates were already close, that is evidence of a search problem, and that is exactly where more intelligence on search earns its keep.

Runtime feedback blurs the boundary

The estimation-versus-search split is useful, and real learned optimizers make it less clean than the diagrams suggest.

Some of them get feedback the normal planning phase never sees. QORL executes candidate plans and observes them. Bao learns from previously measured latency. QORL can also override row estimates outright while it searches. A system like that can sometimes compensate for a cardinality mistake without ever touching PostgreSQL's statistics, which is a real advantage over the stock planner.

It is also a partial one, and it arrives late. On the first encounter with a misestimated query there is nothing to learn from yet; the slow plans have to run before the system knows they were slow. Several candidates may need to execute. The workload has to recur often enough for that exploration cost to amortize. And the optimizer itself brings operational surface that did not exist before: model serving in a path that used to be a deterministic in-process call, fallback behaviour when the model is unavailable or returns something degenerate, plan-cache questions a classical planner answers differently, and revalidation as the data distribution drifts away from whatever the model was trained on. That last one is a second statistics problem, one layer up, that you have taken on in addition to whatever cardinality issues the base system already had.

None of that makes the approach bad. It changes the question I would ask before adopting it. Not "can this beat PostgreSQL's plans?" The experiment already answers that. Rather: what failure in my production system am I paying it to solve?

There is a third axis too

Even estimation-versus-search is not the whole story. A plan can be reasonable on paper and still behave badly at execution. Memory pressure decides whether a hash join spills. Cache state changes I/O. Concurrency changes what resources one query actually gets. Parallel workers may not be available when the plan assumed them. Skew can leave one worker doing most of the work. So the production picture is closer to three stages:

Bad query performance Estimation Search Execution Does PostgreSQL understandthe amount of data? Given those estimates,did it choose well? Did the plan behaveas its cost predicted?
Diagram source
flowchart TD
    A[Bad query performance] --> B[Estimation]
    A --> C[Search]
    A --> D[Execution]
    B --> B1["Does PostgreSQL understand<br/>the amount of data?"]
    C --> C1["Given those estimates,<br/>did it choose well?"]
    D --> D1["Did the plan behave<br/>as its cost predicted?"]

A better optimizer mostly attacks the middle. Runtime feedback gives it some purchase on the third. Neither makes the first question go away.

The optimizer may be innocent

When a plan goes catastrophically wrong, the visible operators take the blame. The nested loop is visible. The sequential scan is visible. The join order is visible. The wrong assumption that led to all of them is not.

So I keep the diagnosis in order. Does PostgreSQL understand roughly how much data flows through this query? Given that, did it choose a good way to execute it? And did that plan behave in production the way its cost model predicted?

Learned optimizers are a serious answer to the second question and are starting to reach into the third. They do not retire the first. If PostgreSQL thinks 1,700 rows are coming when 16 million arrive, I want to know why before I stack another optimizer on top.

Sometimes the planner chose the wrong plan. Sometimes it chose the right plan for the wrong world.

References

hzerrad@insights:~$contact

Is your backend the bottleneck?

I'm a staff-level engineer for teams whose backend has become the constraint. It usually shows up as one of these:

  • A critical path got slow
  • The data flow is a black box
  • Changes feel dangerous
Send a messageWays to work together