JIT Compilation Overhead on Short Queries #
A dashboard query runs in 22 ms from your laptop’s psql session and in 410 ms from the reporting service. The plans are identical node for node. The difference is at the bottom of the application’s plan: a JIT: section reporting 380 ms spent generating, inlining and optimizing machine code for a query that then ran for 30 ms.
JIT is one of the session-level inputs that can make the same SQL behave differently across connections, alongside the settings covered in session parameter drift.
The Cost-Model Condition #
PostgreSQL’s just-in-time compilation (enabled by default since version 12, when built with LLVM) compiles expression evaluation and tuple deforming into native code. The decision is made per query, from the plan’s total estimated cost:
| Setting | Default | Effect when total cost exceeds it |
|---|---|---|
jit |
on |
master switch |
jit_above_cost |
100000 | compile expressions and deforming |
jit_inline_above_cost |
500000 | also inline small functions — expensive |
jit_optimize_above_cost |
500000 | also run LLVM optimization passes — expensive |
Three properties make JIT a trap for short queries:
- It keys on estimated cost, not rows or time. Cost units are planner values, as explained in cost estimation models. A sequential scan over a 1 GB table costs around 130,000 regardless of whether it returns 5 rows.
- Compilation happens on every execution. Compiled code is not cached across executions; a statement run a thousand times compiles a thousand times.
- Misestimates and generic plans inflate cost. An overestimated join or a parallel plan with high setup cost can cross the thresholds even though the query is fast in practice.
And one property makes it a drift problem: jit and its thresholds can be set by ALTER DATABASE, ALTER ROLE, connection options or SET, so two clients can disagree.
Annotated EXPLAIN Evidence #
EXPLAIN (ANALYZE, SETTINGS)
SELECT date_trunc('hour', created_at) AS h, count(*)
FROM page_views
WHERE site_id = 42 AND created_at >= now() - interval '1 day'
GROUP BY 1;
From the reporting service’s role:
HashAggregate (cost=611840.20..612102.40 rows=… width=16) (actual time=398.4..398.6 rows=24 loops=1)
Group Key: date_trunc('hour', created_at)
-> Bitmap Heap Scan on page_views (actual time=392.1..396.2 rows=18420 loops=1)
…
Planning Time: 0.6 ms
JIT:
Functions: 14
Options: Inlining true, Optimization true, Expressions true, Deforming true
Timing: Generation 2.1 ms (Deform 0.6 ms), Inlining 71.3 ms, Optimization 204.8 ms, Emission 102.4 ms, Total 380.6 ms
-- total cost 612,102 > 500,000 → inlining and optimization enabled
-- Total 380.6 ms of JIT inside an execution of 409 ms
Execution Time: 409.2 ms
From psql as a different role:
HashAggregate (cost=611840.20..612102.40 …) (actual time=21.6..21.8 rows=24 loops=1)
Settings: jit = 'off' -- set on this role long ago
Execution Time: 22.1 ms
Fields to read: the JIT: block’s Timing … Total against Execution Time, and Options to see which expensive stages ran. Note that JIT time appears inside actual time of the first nodes to execute compiled code, which is why the Bitmap Heap Scan above looks slow.
Step-by-Step Resolution #
-
Look for
JIT:inEXPLAIN ANALYZEoutput. IfTotalis a large share ofExecution Time, JIT is the problem. -
Find where each side’s setting comes from.
SELECT name, setting, source FROM pg_settings WHERE name LIKE 'jit%'; SELECT r.rolname, d.datname, s.setconfig FROM pg_db_role_setting s LEFT JOIN pg_roles r ON r.oid = s.setrole LEFT JOIN pg_database d ON d.oid = s.setdatabase; -
Test the alternatives in a transaction:
BEGIN; SET LOCAL jit = off; EXPLAIN (ANALYZE) <query>; ROLLBACK; BEGIN; SET LOCAL jit_inline_above_cost = 5000000; SET LOCAL jit_optimize_above_cost = 5000000; EXPLAIN (ANALYZE) <query>; -- keep cheap compilation, skip the expensive stages ROLLBACK; -
Check whether the cost is inflated by an estimate error. If the plan’s cost is high only because of a misestimate, fixing it removes the JIT trigger and improves the plan — see spotting row estimate explosions.
-
Pin the decision where the workload lives, not in each application:
ALTER ROLE reporting_service SET jit = off; -- or keep JIT for real analytics but avoid the expensive stages ALTER DATABASE app SET jit_optimize_above_cost = 5000000; -
Verify from the application’s own connection with
auto_explainorEXPLAIN (ANALYZE, SETTINGS)issued through the same role and pool.
Before and After #
-- BEFORE: reporting_service role, jit on with default thresholds
JIT: Options: Inlining true, Optimization true … Total 380.6 ms Execution Time: 409.2 ms
-- AFTER: ALTER ROLE reporting_service SET jit = off
(no JIT section) Execution Time: 22.4 ms
The query’s plan and cost are unchanged; only the decision to compile disappeared. Other roles in the same database — the nightly analytics jobs — keep JIT and its benefits for their long scans.
Common Pitfalls #
Testing from a session with different JIT settings. Local psql often runs as a role with JIT off or on a build without LLVM. Diagnostic signal: no JIT: block locally, present in production logs. Fix: test through the application’s role, or use EXPLAIN (SETTINGS) to compare.
Turning JIT off server-wide for everyone. Long analytical scans can lose real speedups. Diagnostic signal: large aggregations over hundreds of millions of rows slowing after the change. Fix: scope by role or database.
Blaming the first scan node. Compile time is charged to the nodes that first run compiled expressions. Diagnostic signal: a scan’s startup time roughly equal to the JIT total. Fix: read the JIT: block before investigating the scan.
Missing JIT under parallel plans. Each parallel worker compiles its own code, so JIT time multiplies with workers. Diagnostic signal: JIT: totals above wall-clock execution time in a Gather plan. Fix: raise thresholds for parallel-heavy workloads, as the worker count multiplies the overhead covered in parallel query execution.
Frequently Asked Questions #
What decides whether PostgreSQL uses JIT for a query?
The plan’s total estimated cost. Above jit_above_cost expressions are compiled; above jit_inline_above_cost and jit_optimize_above_cost the expensive inlining and optimization stages are added. Actual runtime is not considered.
Why does JIT hurt short queries? Compilation happens at the start of every execution and can take hundreds of milliseconds, while the benefit is small per row. A query that is expensive on paper but fast in practice never recovers the cost.
Should I turn JIT off?
For OLTP roles and databases, turning it off or raising the thresholds is common. Keep it for analytical workloads by scoping the change with ALTER ROLE or ALTER DATABASE.
Related #
- Session Parameter Drift — parent guide: auditing settings and their sources across connections
- Detecting work_mem Drift Across Sessions — sibling: the same audit applied to memory settings
- EXPLAIN SETTINGS, WAL and SERIALIZE Options — capturing the settings line that exposes the difference