Performance and tuning
David Sterling  

PgBouncer 1.26: Patch Three CVEs, Then Kill p99 Timeouts

PgBouncer 1.26.0 landed on September 23, 2026. Most coverage filed it under “security release,” and that is fair — it fixes three CVEs, two of which are reachable by clients that never authenticate. But the same release also changed three knobs that govern what your pooler does when it runs out of server connections, and those knobs are what shows up in your p99 latency graphs. Read both halves.

What the three CVEs actually cost you

All three are denial-of-service issues. They are not exotic remote code execution, but a pooler sits on the connection path for every application process you run, so “the pooler crashes” and “the site is down” are the same sentence in an incident channel.

  • CVE-2026-19888 — crash, triggerable by an unauthenticated client. Caused by a SCRAM client-final-message sent without a nonce.
  • CVE-2026-6668 — infinite loop from an integer overflow in the packet buffer growth logic. Also reachable pre-authentication.
  • CVE-2026-6669 — unbounded work during login, triggerable by a malicious PostgreSQL server. Caused by an unbounded SCRAM iteration count.

Two of these need nothing but a TCP connection to port 6432. If your pooler is reachable from a shared network or a broad security group, treat this as an emergency patch rather than a maintenance-window item. Details are in the 1.26.0 release announcement.

The tunables that changed (the part that affects latency)

1. query_wait_timeout is now per database and per user

Semantics are unchanged: it is the maximum time a query may wait for a server connection before PgBouncer disconnects the client. 0 disables the limit, which means clients queue indefinitely. -1 means unset, so the value falls through to the database, then the user, then the default.

What is new is that you can finally scope it. Before 1.26 you had one global number, which forced a bad compromise: short enough to protect the OLTP pool, long enough that your nightly batch job did not get killed. Now the fast pool and the slow pool can disagree.

[databases]
app_prod      = host=10.20.0.11 port=5432 dbname=app pool_size=40 query_wait_timeout=2
app_reporting = host=10.20.0.12 port=5432 dbname=app pool_size=8 query_wait_timeout=30

[users]
etl_batch = pool_mode=session query_wait_timeout=60

How to pick the number instead of guessing: measure it. SHOW POOLS reports how long clients actually sat in the queue.

psql -h pooler.internal -p 6432 -U admin pgbouncer -c "SHOW POOLS;"

Look at avg_wait_time and maxwait per pool. A clean baseline for a transactional pool is a p99 queue wait under 50 ms. Set query_wait_timeout to roughly three times your observed worst-case queue wait, then alert at half that. Past that point a request has usually blown its own timeout budget anyway, and failing it fast frees the connection for a request that can still succeed. Queueing indefinitely is the failure mode that turns one slow query into a whole-application outage.

2. pool_idle_timeout (new, and deliberately off)

This frees an entire pool — one database/user pair — once it has had no client connections and no server connections for the configured number of seconds. It is not server_idle_timeout, which closes idle server connections but keeps the pool object alive.

[pgbouncer]
pool_idle_timeout = 900

Default is 0 (disabled), and the reason is worth understanding before you flip it: freeing a pool forgets that pool’s statistics. The per-database totals in SHOW STATS are a sum over currently existing pools, so enabling this makes your throughput dashboards step down whenever a pool is reclaimed — which looks exactly like a traffic drop. Enable it only when pool sprawl is a real problem: hundreds or thousands of distinct tenant user names, each minting its own pool and holding memory. If you do enable it, tell whoever owns the monitoring before they get paged by a graph that lies.

3. search_path and default_transaction_read_only are tracked by default

In transaction pooling mode a server connection is handed to a different client after each transaction commits, so any session-level SET left behind leaks forward. PgBouncer maintains a client variable cache for the parameters Postgres reports to the client and restores them when a client becomes active.

The catch is version-dependent. Postgres only reports search_path to clients starting with version 18 (Citus 12+ also triggers it). On Postgres 17 and earlier, PgBouncer knows only what the client sent in its startup packet — so a SET search_path issued after connecting is not tracked and can leak to the next client on that connection unless you set server_reset_query_always. That leak is a performance problem, not just a correctness one: the next query can resolve to a different table, a different schema, and a completely different plan. If you are on 18, the new default closes the hole for free.

Upgrade gotcha: online restart is gone

The deprecated online restart (-R) has been removed, so you can no longer swap the binary with a signal. Ordinary config reloads via RELOAD or SIGHUP are unaffected, but a version upgrade now needs a real restart with an overlap strategy. PgBouncer’s current configuration documents peering (peer_id and the [peers] section) for running multiple pooler processes together — worth checking whether your topology can use that before you plan a maintenance window. Meson build support was also added, which matters if you package PgBouncer yourself.

A short tuning checklist

  1. Patch first. Two CVEs need only an unauthenticated TCP connection.
  2. Set query_wait_timeout per pool from measured queue-wait data, not from a number somebody remembered from a conference talk.
  3. Cap queue depth with max_db_client_connections (keep it at or above max_db_connections) so one backed-up pool cannot consume every descriptor counted by max_client_conn.
  4. Use reserve_pool_size with reserve_pool_timeout (5 seconds by default) to absorb short spikes without permanently increasing the pool.
  5. Only enable pool_idle_timeout where pool sprawl is measurable, and coordinate with monitoring.
  6. Check your Postgres version against the new default tracking, then decide about server_reset_query_always.

If you are still deciding whether a pooler is worth running at all, we covered that trade-off in PgBouncer vs built-in pooling. If you already run one and want the queue-pressure view, see connection pooling lessons from a very large deployment. Both assume the pooler is patched.

The bottom line

Patch 1.26.0 during your next window — the pre-auth crash is not something to schedule for next month. Then use the release as an excuse to do the tuning you have been deferring: give each pool its own wait timeout, fail fast instead of queueing forever, and confirm the parameter tracking actually matches your Postgres major version. The CVE list gets the upgrade approved; the timeout work is what your users feel.

Leave A Comment