- Book: Database Playbook
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A couple of extra INSERTs land in a table. The planner's cost estimate for a query that used to finish in a few milliseconds creeps over 100,000. Now Postgres compiles that query with LLVM before running it, and most of its runtime goes to compilation. Nobody changed the query.
That scenario comes almost word for word from the commit that turns JIT off by default in PostgreSQL 19, written by Jelte Fennema-Nio and pushed by Tomas Vondra on 2 April 2026: "Sometimes seemingly unrelated changes (for example a couple additional INSERTs into a table) increase the cost just enough to enable JIT, resulting in a performance cliff." The same message says most large deployments already disable JIT, "Notably, this includes all hyperscalers."
PostgreSQL 19 is at RC 1, with general availability planned for November 19, 2026. The release notes put it plainly: JIT's cost-based triggering "has been determined to be unreliable," and the change "requires sites that are doing many large analytical queries to manually enable JIT."
So before you upgrade, you want two lists. The queries JIT was hurting, which get faster for free. And the queries JIT was helping, which get slower unless you act. Both lists are already sitting in your current database.
What JIT does, and how the old default decided
Postgres JIT compiles parts of a query to native code through LLVM. Per the docs, it currently accelerates two things: expression evaluation (WHERE clauses, target lists, aggregates, projections) and tuple deforming, which turns an on-disk row into its in-memory form. It only exists if the server was built --with-llvm.
The decision is made at plan time by comparing the query's total estimated cost against three thresholds, documented in the query planning settings:
-
jit_above_cost, default 100000: compile at all. -
jit_inline_above_cost, default 500000: inline small functions and operators. -
jit_optimize_above_cost, default 500000: run the expensive LLVM optimizations.
Those thresholds stay the same in 19. What changes is jit itself, which goes from on in PostgreSQL 18 to off. The thresholds only matter once you turn it back on.

The old default: cross a cost line, pay for compilation, whether the query needed it or not.
Read the JIT block in EXPLAIN ANALYZE
Run SHOW jit; first. The commit says most large deployments, hyperscalers included, already run with JIT disabled. If yours reports off, there is nothing for 19 to change here and you can skip to the other defaults at the end.
If it reports on, start with one query you suspect:
EXPLAIN (ANALYZE, BUFFERS) SELECT customer_id, sum(amount) FROM orders WHERE created_at >= now() - interval '90 days' GROUP BY customer_id; If JIT fired, the plan ends with a block like the one in the official "When to JIT?" page. That page forces JIT onto a tiny pg_class aggregate by lowering jit_above_cost to 10, and the output reads:
JIT: Functions: 3 Options: Inlining false, Optimization false, Expressions true, Deforming true Timing: Generation 1.259 ms (Deform 0.000 ms), Inlining 0.000 ms, Optimization 0.797 ms, Emission 5.048 ms, Total 7.104 ms Execution Time: 7.416 ms (Line breaks added to fit; Postgres prints each of those on one line.)
Three things to read off it. Functions is how many functions LLVM produced. Options tells you whether the inline and optimize thresholds were crossed too. Timing is the bill, and in that example the bill is almost the whole query: 7.104 ms of JIT inside a 7.416 ms execution. Without JIT, the same page shows that query at 0.365 ms. That is the cliff from the commit message, in miniature.
The Total also equals Generation + Inlining + Optimization + Emission (1.259 + 0 + 0.797 + 5.048). Deform is printed inside Generation's parentheses, which matters for the next query.
Rank every query that used JIT with pg_stat_statements
EXPLAIN covers one query. pg_stat_statements covers all of them, and its jit_* columns are not new in 19; they are in the PostgreSQL 18 docs too. If you don't run it yet, it needs pg_stat_statements in shared_preload_libraries (a restart), compute_query_id at auto or on, and CREATE EXTENSION pg_stat_statements in the database.
The columns you want: jit_functions, jit_generation_time, jit_inlining_time, jit_optimization_time and jit_emission_time, all times in milliseconds. Sum the four times the same way EXPLAIN's Total does, and leave jit_deform_time out because EXPLAIN reports deform as part of generation:
WITH j AS ( SELECT queryid, calls, total_exec_time, mean_exec_time, jit_functions, jit_generation_time + jit_inlining_time + jit_optimization_time + jit_emission_time AS jit_ms, query FROM pg_stat_statements WHERE jit_functions > 0 ) SELECT queryid, calls, round(mean_exec_time::numeric, 1) AS mean_ms, round((jit_ms / calls)::numeric, 1) AS jit_ms_per_call, round((100 * jit_ms / nullif(total_exec_time, 0))::numeric, 1) AS jit_pct, left(query, 50) AS query FROM j ORDER BY jit_ms DESC LIMIT 20; jit_ms_per_call is averaged over every call of the statement, including calls where JIT may not have fired, so treat it as a ranking signal, not an exact per-execution cost.
Ordering by total JIT time favours chatty statements that run thousands of times. Run the query a second time with ORDER BY mean_exec_time DESC so the rarely-run reporting queries don't get crowded out of the top 20.
Sort the output into two buckets.
High jit_pct, small mean_ms. Say a query has a mean of a few milliseconds and JIT accounts for most of it. That query is a victim of the old default. On 19 it stops paying that tax with no action from you. Expect OLTP-style queries here, whose estimated cost drifted over the threshold.
Low jit_pct, large mean_ms. Say a reporting query runs for tens of seconds and JIT is a fraction of a second of that. That is the opposite case. Compilation was cheap relative to the run, and the compiled expressions may have been saving real CPU time on every row. These are the queries that can get slower after the upgrade. They are your benchmark candidates.
If track_planning is off (the default), planning time is not in these numbers. JIT compile time lands in execution time, which is what the EXPLAIN example above shows.

Two buckets: queries JIT was taxing, and queries JIT was carrying.
Benchmark one query with jit on and off, before you upgrade
You don't need PG 19 to preview its default. On 18, SET jit = off gives you the new behaviour for that session. Take a query from the second bucket, save it as report.sql, and run it through pgbench twice, setting jit through the libpq PGOPTIONS environment variable:
# current default on 18 PGOPTIONS='-c jit=on' pgbench -n -f report.sql \ -c 1 -T 120 analytics # PostgreSQL 19's default PGOPTIONS='-c jit=off' pgbench -n -f report.sql \ -c 1 -T 120 analytics -n skips vacuuming pgbench's standard tables, and the docs call it necessary for a custom script like this one. Compare the latency average line from each run. One client keeps it about the query, not about concurrency; raise -c afterwards if the real workload is concurrent. Run each pair more than once, and on a replica or staging copy with production-sized data, because the whole point is that JIT's behaviour depends on the planner's cost estimate, and that estimate depends on the data.
For a single quick look instead of a timed run, flip it inside a transaction in psql:
BEGIN; SET LOCAL jit = off; EXPLAIN (ANALYZE) SELECT ...; -- your query ROLLBACK; One caveat from the docs: the JIT decision is made at plan time. With prepared statements on a generic plan, the settings in effect when the statement was prepared win, not the ones at execution. If your app uses a driver that prepares statements, benchmark through that path too.
Turn JIT back on for the analytics workload only
If the benchmark says a workload is faster with JIT, don't flip it back on server-wide. That would undo the protection 19 just gave your OLTP traffic. Scope it to where it earned its keep.
Per ALTER ROLE, a role setting becomes the session default at login and overrides postgresql.conf. A role-in-database setting beats a role setting, which beats a database setting:
-- the reporting role, everywhere ALTER ROLE reporting SET jit = on; -- or only in the warehouse database ALTER ROLE reporting IN DATABASE warehouse SET jit = on; -- or every session in that database ALTER DATABASE warehouse SET jit = on; These apply to new sessions only, so recycle your connection pool afterwards. SET ROLE does not pick up role settings; only a real login does. Check what a session ended up with:
SHOW jit; SELECT pg_jit_available(); pg_jit_available() returns true only when a JIT provider is installed and jit is on, so it also catches a package built without LLVM. Leave the three cost thresholds at their defaults unless your benchmark gives you a reason to move them.
The other defaults that change in the same release
While you're reading the PostgreSQL 19 release notes, three more default changes belong on the same upgrade checklist:
-
max_locks_per_transactiongoes from 64 to 128. The notes say lock size allocation changed, so "effectively settings must now be doubled to match their capacity in previous releases." If you had raised it to 256 on 18, the equivalent is 512 on 19. It can only be set at server start. - TOAST compression defaults to
lz4instead ofpglz, throughdefault_toast_compression. That is only when the server was compiled--with-lz4; otherwise it stayspglz. A column's ownCOMPRESSIONoption still overrides the default. -
log_lock_waitsis on by default. Any session that waits longer thandeadlock_timeout(default1s) for a lock now writes a log line. Expect more log volume on a contended system, and a useful new signal if you had never enabled it.
The JIT change is the one that can quietly move query latency in both directions. Run the pg_stat_statements query this week, benchmark the top of the second bucket, and write the ALTER ROLE lines before the planned November 19 release rather than after the first slow dashboard.
If this was useful
Most of this post comes down to one skill: knowing your workload well enough to tell the transactional queries from the analytical ones, then setting up the database for each. The Database Playbook is my book about that kind of decision at the level above a single setting: which store fits which workload, and what you sign up for when you pick one.
