Блог

PostgreSQL 19 disables JIT by default

Юрий Мельников

PostgreSQL 19 disables JIT by default. Let’s look at why this optimization became a risk.

Earlier, I wrote about a query that took about 52 seconds with JIT enabled and about 7 seconds without it. At the time, it looked like an unpleasant edge case: the planner considered the query expensive enough to justify JIT compilation, but LLVM consumed almost all of the time that was supposed to be saved.

Now that story has an unexpected continuation. In PostgreSQL 19, JIT will be disabled by default. At the time of writing, PostgreSQL 19 is in Beta 3, so it is still too early to use it in production. But the decision has already landed in the future release branch, and the release notes state the reason plainly: the old cost model was not reliable enough.

So we have almost come full circle. JIT appeared in PostgreSQL 11 disabled by default. In PostgreSQL 12, it was enabled by default. And in PostgreSQL 19, the default changes back to off.

This does not mean JIT turned out to be useless. It means something else: the database will no longer try to automatically guess whether compilation will pay off when the cost of being wrong may be too high.

What exactly changes in PostgreSQL 19

In PostgreSQL 18, the jit parameter defaults to on. That does not mean JIT runs for every query. It only kicks in when the estimated cost of the plan exceeds jit_above_cost. The default threshold is 100000.

In PostgreSQL 19, the default value of jit changes to off. The rest of the mechanism does not disappear: JIT can still be enabled globally, or for a specific role, session, transaction, or query.

It is important to check the actual configuration of your database instead of drawing conclusions from the version number alone:

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

Upgrading PostgreSQL does not necessarily rewrite an existing configuration. The new default mainly defines safer initial behavior. So after an upgrade, it is still worth running SHOW jit and checking the actual value.

Why the cost-based decision did not work

PostgreSQL decides whether JIT is needed by comparing the total plan cost with three thresholds:

  • jit_above_cost enables code generation;

  • jit_inline_above_cost allows inlining of functions and operators;

  • jit_optimize_above_cost enables more expensive LLVM optimizations.

By default, these are 100000, 500000, and 500000, respectively.

The problem is that planner cost is not execution time, and it is not a latency forecast. It is an internal value the planner uses to compare alternative plans. It depends on row count estimates, operation costs, page reads, and other factors, but it does not know how many milliseconds LLVM will spend generating and optimizing code on this particular server.

As a result, two different decisions are made based on the same number:

  1. The planner chooses what it believes is the cheapest plan.

  2. Based on the final cost of that plan, PostgreSQL decides whether to run JIT.

For the first task, relative cost is usually useful. For the second, the real question is different: will faster execution be enough to pay back the compilation time? Planner cost cannot answer that reliably.

That creates a performance cliff. A small change in statistics or table size can push the plan cost just above the threshold. The plan itself barely changes, but suddenly there is an extra compilation phase. A query that yesterday fit comfortably into tens of milliseconds can become noticeably slower today.

This is exactly what PostgreSQL developers pointed to when changing the default. The discussion also noted that wider use of partitioning has increased the number of expressions that need to be compiled, while LLVM has come to spend more time on this work. For short and medium queries, the risk of a loss turned out to matter more than the possible automatic win.

JIT does not compile the whole query

The name can easily create the wrong mental model, as if PostgreSQL turned the entire query plan into one native program. In practice, JIT accelerates narrower parts of execution:

  • expression evaluation in WHERE, the target list, aggregates, and projections;

  • tuple deforming — converting rows from PostgreSQL’s internal format into values the executor can work with;

  • inlining some short functions and operators;

  • optimizing generated code through LLVM.

JIT does not make a bad plan good. It does not add an index, fix a wrong row count estimate, or eliminate expensive disk reads. If a query is bottlenecked on I/O, locks, or network transfer, the benefit from compiling expressions may be minimal.

The best candidate for JIT is a long-running CPU-bound query that repeatedly evaluates the same non-trivial expressions over a large number of rows. That is why JIT is more often useful in analytics than in OLTP.

How to tell whether JIT is the problem

Start with the execution plan:

EXPLAIN (ANALYZE, BUFFERS)
SELECT ...;

If JIT was used, the result will include a separate block:

JIT:
  Functions: ...
  Options: Inlining ..., Optimization ..., Expressions ..., Deforming ...
  Timing: Generation ... ms, Inlining ... ms,
          Optimization ... ms, Emission ... ms, Total ... ms

Here it is useful to look not only at Total, but also at the time breakdown:

  • Generation — creating intermediate code;

  • Inlining — inlining functions;

  • Optimization — LLVM optimizations;

  • Emission — generating machine code.

The simplest control experiment is to run the same query with and without JIT:

BEGIN;

SET LOCAL jit = off;

EXPLAIN (ANALYZE, BUFFERS)
SELECT ...;

ROLLBACK;

Then repeat the measurement with SET LOCAL jit = on.

One run is not enough. The first query may read data from disk, while the next one may already hit cache. For a fair comparison, you need several runs under the same conditions, with the same parameters, and you should check not only the average time but also the spread. In production, EXPLAIN ANALYZE should also be used carefully: it really executes the query.

Prepared statements add another surprise

The decision to use JIT is made when the plan is built, not when it is executed. This matters especially for prepared statements and connection pools.

If PostgreSQL uses a generic plan, the decision is affected by the settings that were active when the plan was prepared. Changing thresholds before the next EXECUTE may not be enough: the old generic plan already contains the decision. The jit = off parameter applies both during planning and execution, but when diagnosing this, it is still important to take the lifecycle of the prepared plan into account.

The practical takeaway: test JIT through the same connection and in the same way the application runs the query. A test from a separate psql session may fail to reproduce the behavior of your connection pool.

How I would configure JIT now

For a typical backend application with short queries, I would use jit = off as the baseline. In PostgreSQL 19 this becomes the standard default, but the same policy can already be applied today on PostgreSQL 18 and earlier supported versions.

A targeted setting for the application role:

ALTER ROLE app_user SET jit = off;

For a separate analytics role:

ALTER ROLE analytics_user SET jit = on;

This way, OLTP traffic does not pay for unexpected compilation, while heavy analytical queries keep the option to use JIT. In a mixed workload, this is usually easier to understand and more predictable than one global threshold for every application.

If JIT really helps analytics, the thresholds should be tuned on real queries and real hardware. There is no universal value: compilation cost depends on expression complexity, the number of generated functions, the LLVM version, and the CPU, while the potential gain depends on the nature of the workload.

There is also a middle-ground option: keep basic code generation, but avoid expensive optimizations and inlining. You do not have to change jit for that; you can control jit_inline_above_cost and jit_optimize_above_cost separately. But this is also a setting that should be validated with measurements, not with the feeling that “a little JIT” is always safer.

So where does that leave us?

When I first ran into the slowdown from 7 to 52 seconds, disabling JIT felt almost like a workaround. Now PostgreSQL 19 makes jit = off the new default for the same reason: an automatic decision based on planner cost can sometimes be wrong in a very expensive way.

This is an important signal, but not a final verdict on the technology. JIT can still speed up large CPU-bound queries. What changes is the responsibility: PostgreSQL used to try enabling it automatically, and now the user is expected to prove that it helps on their workload first.

To me, this is a more honest default. Predictable performance matters more than an optimization that sometimes works brilliantly and sometimes turns a small data change into an unexpected latency spike.

The short rule now is: for OLTP, start with jit = off; for analytics, enable JIT after measuring; when you see a sudden slowdown, always check the JIT block in EXPLAIN ANALYZE.

Sources