Slow Queries7 min read

PostgreSQL JIT Compilation: When the Planner Makes Queries Slower

JIT compilation is supposed to make big PostgreSQL queries faster, but bad cost estimates fire it up on cheap ones. Here is how to spot the regression and switch it off safely.

I found the JIT problem the way most people do: a query got slower after an upgrade and nobody had touched the query. The database had moved from an old major version to PostgreSQL 14, everything else was unchanged, and one reporting endpoint went from a steady 80 milliseconds to well over half a second. The plan shape in EXPLAIN looked identical to before. Same indexes, same join order, similar row counts. The difference was at the bottom of the output, in a section I had never had to read carefully: JIT. Functions: 12. Timing: Generation 9 ms, Inlining 21 ms, Optimization 260 ms, Emission 180 ms. The server was spending close to half a second compiling machine code in order to run a 60-millisecond query, on every single execution.

That is the JIT cost regression in its purest form, and it is more common than the release notes suggest. The fix is easy once you know the mechanism, so let me walk through it the way I wish someone had walked me through it.

What JIT actually does in PostgreSQL

Since PostgreSQL 11, the executor can use LLVM to compile parts of a query plan into native machine code. Two things get compiled: expression evaluation, which is the work of computing WHERE clause predicates, join conditions, and target list expressions row by row, and tuple deforming, which is the work of cracking open on-disk row format into individual column values. For queries that chew through tens of millions of rows, interpretation overhead on those two paths is real, and compiled code is genuinely faster. That is the honest case for JIT, and I will come back to it, because there are workloads where you want it.

The planner does not compile every query. It gates the decision on cost: if the estimated total cost of the plan exceeds jit_above_cost, JIT compilation is attempted, and two further thresholds control the more aggressive passes, jit_optimize_above_cost and jit_inline_above_cost, which enable LLVM's optimization pipeline and function inlining respectively. The defaults look like this:

SHOW jit;
SHOW jit_above_cost;
SHOW jit_inline_above_cost;
SHOW jit_optimize_above_cost;

Out of the box, jit is on, jit_above_cost is 100000, and both of the aggressive thresholds are 500000. The critical detail is what that 100000 is measured in: planner cost units, an abstract number derived from estimated row counts, selectivities, and I/O assumptions. It is not milliseconds, and it is only as truthful as the statistics behind it.

How a cheap query ends up paying for compilation

Here is the failure mechanism. The planner estimates that a query will read five million rows because the table statistics say the predicate is not selective. Maybe the stats are stale, maybe the predicate correlates two columns the planner assumes are independent, maybe a parameterized plan got a generic estimate that has nothing to do with the values the application actually passes. Whatever the cause, the estimated total cost sails past 100000, the planner concludes this is a heavyweight query, and it schedules JIT compilation. Then execution starts, the predicate turns out to match forty thousand rows, and the whole thing runs in 60 milliseconds, plus the several hundred milliseconds LLVM spent generating, optimizing, and emitting code.

Two properties make this sting. First, JIT compilation happens per execution. There is no cache of compiled plans; a connection pooler handing you a fresh backend, or even the same backend running the query again, compiles again. Second, the queries most likely to be misestimated are often the latency-sensitive ones: dashboard endpoints and API calls with user-supplied filters, where a half-second regression is a user-visible incident, not a rounding error. My upgrade story had both properties at once, and the connection pooler made sure every request paid the compilation bill individually.

The optimization and inlining passes deserve special mention, because they are usually the expensive part. Generation and emission are measured in tens of milliseconds; Optimization and Inlining are where LLVM really goes to work, and on plans that cross those 500000 thresholds I have watched those phases take longer than the query would have run interpreted by an order of magnitude.

Reading the JIT section of EXPLAIN

Diagnosis starts with the plan itself. Any time a query is slower than its plan shape justifies, run it with ANALYZE and read the footer:

EXPLAIN (ANALYZE)
SELECT o.customer_id, count(*), sum(o.total)
FROM orders o
WHERE o.status = 'open'
  AND o.region = 'emea'
GROUP BY o.customer_id;

If JIT fired, the output ends with a block reporting how many functions were compiled and how long each phase took: Generation, Inlining, Optimization, Emission. The rule of thumb I use: if the total JIT timing is the same order of magnitude as the reported execution time, or larger, and the query is latency-sensitive, you are paying the regression tax. If the query runs for four minutes and JIT added two seconds, close the ticket and move on, because that is JIT doing exactly what it was built to do.

For a fleet-wide view, pg_stat_statements grew JIT counters in PostgreSQL 15, which means you can find every query family that is compiling without running EXPLAIN by hand. One trap in those columns: the JIT time fields are cumulative totals, just like total_exec_time, so they are only comparable to mean_exec_time after you divide by calls:

SELECT substring(query for 60) AS q,
       calls,
       round(mean_exec_time::numeric, 2) AS mean_ms,
       jit_functions,
       round((jit_generation_time / calls)::numeric, 1) AS gen_ms,
       round((jit_inlining_time / calls)::numeric, 1) AS inline_ms,
       round((jit_optimization_time / calls)::numeric, 1) AS opt_ms,
       round((jit_emission_time / calls)::numeric, 1) AS emit_ms
FROM pg_stat_statements
WHERE jit_functions > 0
ORDER BY (jit_generation_time + jit_inlining_time
        + jit_optimization_time + jit_emission_time) / calls DESC
LIMIT 10;

When that query shows a familiar OLTP statement with mean execution in the tens of milliseconds and JIT time in the hundreds, you have found your regression without leaving the monitoring database.

Turning it off safely: per query, per role, per system

The blunt instrument is ALTER SYSTEM SET jit = off, and I will not pretend I have never used it during an incident. But it throws away the genuine analytical wins, and there are three more surgical options. For one known-bad query, scope the change to its transaction:

BEGIN;
SET LOCAL jit = off;
-- the expensive report runs here, without compilation overhead
COMMIT;

ALTER ROLE reporting SET jit = off;

ALTER SYSTEM SET jit_above_cost = '1000000';
SELECT pg_reload_conf();

The per-role line is the workhorse. Most real systems have cleanly separated workloads: an application role running short OLTP statements that will never benefit from JIT, and an analytics role running the scans that might. Setting jit = off on the application role, or just on the reporting role whose estimates are unreliable, removes the tax where it hurts and keeps the benefit where it pays. The third option, raising jit_above_cost, keeps JIT available but demands a plan the planner genuinely believes is enormous before LLVM gets involved. Raising it by a factor of ten is a conservative starting point that fixes most accidental firings while leaving honest analytical queries alone.

And do not skip the root cause: a query whose estimated cost is wildly wrong is telling you the statistics are wrong. Run ANALYZE on the involved tables, look at row estimates versus actuals in the plan, and if the misestimate comes from correlated predicates, that is what extended statistics with CREATE STATISTICS exist for. Fixing the stats often fixes the plan shape too, which is worth more than the JIT savings. The companion guide to reading EXPLAIN ANALYZE output covers how to compare estimated and actual rows node by node.

When JIT genuinely earns its keep

I want to be fair to the feature, because the operations community's reflex to disable it everywhere is an overcorrection. On a long analytical scan that filters hundreds of millions of rows through a non-trivial expression, JIT-compiled predicates measurably cut CPU time. Aggregates over huge intermediate results benefit. Queries on wide rows, where tuple deforming dominates, benefit most of all. The common thread is that compilation cost gets amortized across enough rows that the per-row savings win. The test is always empirical: run the real query with jit on and off, on real data volume, and keep whichever wins. Just make sure the query actually resembles your production execution, because testing on a small restore is exactly how JIT looks free when it is not.

Catching JIT regressions with MonPG

JIT regressions are step changes: a query family that held a stable latency for months doubles overnight, usually right after an upgrade, a statistics change, or a data growth milestone that pushed estimates past the threshold. That is precisely the shape slow query monitoring is built to surface. Because MonPG keeps pg_stat_statements history per query family for the PostgreSQL servers it watches today, the before-and-after comparison is already on screen when a deployment turns a cheap query into a compiling one, and wait-event and planning-time context separates compilation overhead from genuine execution slowdowns. When the p99 graph jumps and the answer turns out to be LLVM, the history is what lets you prove it in minutes instead of rolling back the upgrade to find out.