JIT Compilation in PostgreSQL
Today I encountered a counterintuitive situation where JIT compilation not only doesn't help but actually harms! How is it possible that with JIT compilation enabled, query execution time increases eightfold? Let's try to figure it out.
A bit of history
JIT (Just-In-Time compilation) in PostgreSQL emerged as an attempt to speed up expensive queries by compiling part of the execution plan into native machine code. The idea is to move away from interpreting expressions (filters, calculations, aggregates) and execute them as close to the hardware as possible. For this, PostgreSQL uses LLVM, which generates and optimizes machine code on the fly for a specific query.
JIT was primarily intended for CPU-intensive analytical scenarios: complex expressions, large data volumes, long queries where the cost of compilation is justified by execution time. In such cases, the gain is achieved by reducing interpreter overhead and more aggressive optimizations that are impossible or impractical to do in advance. In OLAP workloads, this can indeed provide a noticeable boost—but only if the query is 'heavy enough' for JIT to justify itself.
JIT was not designed for typical OLTP.
JIT appeared in PostgreSQL 11 as an experimental and disabled-by-default feature. Starting with PostgreSQL 12, JIT is enabled by default (jit = on), but with an important caveat: it only triggers when cost thresholds are exceeded (jit_above_cost, jit_inline_above_cost, jit_optimize_above_cost).
PostgreSQL developers assumed that PostgreSQL has long been actively used as an OLAP or analytical database: reports, ad-hoc analytics, heavy SELECT with aggregations, and so on. Such queries run for seconds and minutes, contain complex expressions, are less frequently executed, but 'burn' the CPU. In this scenario, the cost of JIT compilation is drowned in the overall execution time, and speeding up expressions and filters makes sense.
And this is where the divergence from reality begins.
In practice, PostgreSQL is most often used as an OLTP database for web applications: CRUD, APIs, backends, microservices. The predominant characteristics of such workloads are short queries (milliseconds), high frequency, many simple SELECT / INSERT / UPDATE. For this class of workloads, JIT overhead often doesn't pay off, and even a 'costly' query by planner cost can be short in fact, while compilation + LLVM can take comparable time to execution.
The versatility of PostgreSQL worked against itself.
PostgreSQL is both OLTP and OLAP, actively used in analytics and often deployed as a 'universal database.' Therefore, the decision was made to enable JIT globally but limit it through cost-based thresholds and leave the final tuning to engineers for specific workloads. This is reasonable from the core DBMS perspective but dangerous in production if you don't look at EXPLAIN ANALYZE.
In PostgreSQL, it's important to understand one thing: planner cost is not time and not even an attempt to guess it. It's an abstract model of the relative 'costliness' of a plan, used only for plan selection, not for latency estimation.
Planner cost is measured in arbitrary units, not tied to milliseconds, does not account for actual CPU performance, and knows nothing about the current system load. This means it represents rough heuristics, not measurements.
Planner Cost works well when the plan runs for a long time and the difference between plan options is significant, but for short queries where execution takes 5–20 ms, JIT adds 10–30 ms, and the planner still considers it a 'costly' query. From the cost perspective, it 'pays off,' but from the user's perspective, it objectively became slower.
How to properly limit or disable JIT in PostgreSQL
It's important to immediately note: JIT is not a binary choice. It doesn't have to be either 'enabled for everyone' or 'disabled forever.' In PostgreSQL, there are several levels of control—from careful tuning to complete disabling.
The safest option: disable JIT at the session level. Suitable for OLTP services, APIs, background workers, and batch tasks with predictable queries. This is good because it doesn't affect other connections, is easy to re-enable, and can be applied selectively (connection pool, role, application). Often this is the optimal default for backend services.
JIT is not a single button but a set of stages: generation, inlining, optimization, emission. In some scenarios, it's reasonable to leave JIT enabled but disable the most expensive stages. This reduces overhead while retaining some gains for large queries.
Complete global disabling is an extreme option and makes sense if there are no analytical queries, CPU is a costly resource, or the database is purely OLTP. This is an honest and valid decision, not an 'anti-pattern.'
Personal experience
Today we encountered a telling situation in PostgreSQL: a complex calculated VIEW with several JOIN, many CASE, round(), and numeric arithmetic took about 52 seconds to execute. At first glance—a typical 'heavy' query. However, EXPLAIN ANALYZE showed that the real problem was not in the data or sorting, but in JIT compilation: about 47 seconds were spent solely on LLVM JIT (thousands of generated functions, tens of seconds on optimization and emission). Execution itself took only a small part of the total time.
The solution was trivial: SET jit = off. Without rewriting the query, without indexes, and without changing the logic, execution time was reduced from ~52 to ~7 seconds. This case well illustrates that cost-based JIT inclusion does not guarantee time savings: planner cost is an abstract model, not a latency metric.
JIT in PostgreSQL is a powerful tool, but by default, it optimizes abstract cost, not real time. In production, the winner is the one who understands this and manages it consciously.