PgBouncer has quietly become mandatory infrastructure for PostgreSQL at scale. If you run more than a few hundred client connections against a Postgres instance, you almost certainly have it in the path — and if you run serverless or connection-pooled compute (Lambda, Cloud Run, Kubernetes sidecars that scale to zero), you absolutely do. Version 1.26.0, released September 23, 2026, is the most consequential release in years: three CVEs including two pre-auth denial-of-service bugs, a default behavior change that fixes one of the longest-standing footguns in transaction pooling, and a handful of operational features that change how you should size pools. Here is what matters and what to do about it.
Three CVEs, two of them pre-authentication
The security fixes are the headline, because two of the three can be triggered by anyone who can open a TCP connection to your pooler — no credentials required.
- CVE-2026-19888 — PgBouncer did not verify that the SCRAM client-final-message contained a nonce. An unauthenticated remote attacker could crash the process with a malformed final message. The crash applies even to nonexistent users, because PgBouncer deliberately runs a mock SCRAM exchange for unknown accounts. Present since SCRAM support arrived in 1.11.0.
- CVE-2026-6668 — an integer overflow in packet buffer growth could hang PgBouncer in an infinite loop, reachable without authentication when
max_packet_sizeis at its default. Because PgBouncer is single-threaded, “hang” means the entire pooler stops serving every client and database until someone kills the process. - CVE-2026-6669 — PgBouncer did not bound the SCRAM iteration count it accepted from a server. A malicious or compromised PostgreSQL backend could make the pooler perform unbounded work during a single login, stalling everything else in the meantime. The iteration count is now capped at 1,000,000.
The architectural lesson in all three is the same one PgBouncer has been teaching for a decade: a single-threaded multiplexer is an amplifier for any per-connection work you fail to bound. Every one of these bugs converts one connection’s malformed input into a fleet-wide outage, precisely because all clients share one event loop. If you expose PgBouncer to anything less trusted than your own VPC, 1.26.0 is not an optional upgrade.
Worth noting: this is the second round of SCRAM-related security fixes in six months (1.25.2 in May fixed four CVEs, two of them in the same SCRAM code path). If your patch cadence for the pooler is “when we get around to it,” you are carrying exploit-reachable code on your most centralized network hop.
The search_path fix everyone has been waiting for
The most important feature in the release has no CVE number. In transaction pooling mode, server connections are shared: client A runs some statements, the connection returns to the pool, client B picks it up and runs its own. Anything client A leaves behind that is not cleaned up leaks into B’s session.
Historically, SET search_path was exactly such a leak. A client that ran SET search_path = tenant_a poisoned the pooled server connection, and a later client silently resolved unqualified table names against the wrong schema. Multi-tenant applications learned to work around this in painful ways: fully qualifying every table name, wrapping everything in transactions that reset state, or abandoning transaction pooling for session pooling and giving up its connection-density benefits. The same class of problem applied to default_transaction_read_only.
From 1.26.0, PgBouncer tracks all parameters that PostgreSQL reports by default — most notably search_path (on PostgreSQL 18+) and default_transaction_read_only (on PostgreSQL 14+). When a client sets one of these parameters, PgBouncer records it as part of that client’s state and restores the correct value before handing the server connection to the next client. The workaround era is over, with two caveats: the search_path tracking requires PostgreSQL 18 or newer on the server side, and default_transaction_read_only requires 14 or newer. On older servers, the leak still exists and the old workarounds still apply.
The release codename — “Ignore all previous search_path” — tells you how long the community has been waiting for this one.
What this changes for multi-tenant Go services
A common multi-tenant pattern is a shared schema set with per-tenant schemas, where the service sets search_path per request. On PgBouncer 1.26 with PostgreSQL 18, that pattern now works correctly through transaction pooling. The Go side needs to make the per-request parameter setting explicit and scoped to the request’s transaction:
package repo
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
)
// WithTenant runs fn with search_path pinned to the tenant's schema.
// The SET and RESET travel with the transaction, so a pooled server
// connection is clean by the time the next tenant borrows it.
func WithTenant(ctx context.Context, pool Pool, tenantSchema string, fn func(pgx.Tx) error) error {
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
// schema is interpolated because identifiers cannot be bound
// as query parameters; validate it against an allow-list first.
if !validSchemaName(tenantSchema) {
return fmt.Errorf("invalid schema name %q", tenantSchema)
}
if _, err := tx.Exec(ctx,
fmt.Sprintf("SET LOCAL search_path = %s", pgx.Identifier{tenantSchema}.Sanitize()),
); err != nil {
return err
}
if err := fn(tx); err != nil {
return err
}
return tx.Commit(ctx)
}
type Pool interface {
Begin(context.Context) (pgx.Tx, error)
}
func validSchemaName(s string) bool {
for _, r := range s {
isLower := r >= 'a' && r <= 'z'
isDigit := r >= '0' && r <= '9'
isSep := r == '_'
if !isLower && !isDigit && !isSep {
return false
}
}
return len(s) > 0
}
Note the use of SET LOCAL rather than plain SET: it scopes the change to the current transaction, so the parameter reverts at commit even before PgBouncer’s own tracking kicks in. Defense in depth across both layers — driver and pooler — is the right posture for tenant isolation. Also note the schema name is validated and passed through pgx.Identifier.Sanitize() rather than formatted directly: identifiers cannot be bound as prepared-statement parameters, which is exactly why schema-name interpolation is a recurring SQL injection vector in multi-tenant code.
Pool growth, statistics, and upgrades
Two operational features in 1.26.0 solve quieter but very real problems.
First, pool_idle_timeout. PgBouncer creates one pool per (user, database) pair, and until now those pools never went away — even if no client had used them for weeks. Environments with many infrequent users (analytics tools with per-analyst credentials, multi-tenant setups with thousands of tenant users) watched pool counts — and the memory and file descriptors behind them — grow without bound. The new setting reaps pools idle for longer than the timeout. It is disabled by default because removing a pool also removes its statistics, so pick a threshold that preserves the stats you actually use.
Second, client_login_count in SHOW STATS. Successful client logins are now counted, which makes connection churn visible. This matters more than it sounds: an application whose connection pool is misconfigured to open and close connections constantly — or a serverless deployment that never reuses connections — shows up as login churn long before it shows up as a latency problem, because every login pays the full authentication cost. Through PgBouncer with SCRAM, that includes the SCRAM exchange itself; at high churn rates, the pooler spends real CPU just re-authenticating the same clients. A rising client_login_count against a flat query rate is the earliest signal that a client’s pool settings are wrong.
One breaking change to plan for: the deprecated online restart functionality (-R takeover, plus the SUSPEND and SHOW FDS commands that existed only to support it) has been removed. If your deploy scripts still use takeover for zero-downtime restarts, the replacement is rolling restarts with so_reuseport — run multiple PgBouncer processes on the same port, drain and restart them one at a time. The migration is small, but it is a script change, and discovering it during an incident is the wrong time.
Upgrade checklist
- Upgrade promptly if the pooler is reachable by anything beyond your trusted network — two of the three CVEs are pre-authentication.
- Check your PostgreSQL version before celebrating the
search_pathfix: it needs PG 18+ on the backend. On PG 14–17 you getdefault_transaction_read_onlytracking; on older servers, keep the old workarounds. - Watch for
SET search_pathsurprises after upgrading: some applications relied (unknowingly) on the leak to keep settings across transactions. Tracking changes behavior, and “it broke when we upgraded PgBouncer” is a real diagnostic path. - Set
pool_idle_timeoutif you have many infrequent users, and accept the statistics loss on reaped pools. - Replace takeover-based restarts with
so_reuseportrolling restarts before 1.26.0 reaches your fleet. - Add
client_login_countto your dashboards and alert on churn — it is the cheapest early-warning signal for client-side pool misconfiguration.
PgBouncer sits on the critical path of nearly every serious Postgres deployment and gets far less operational attention than the database behind it. 1.26.0 is a reminder that it is also attack surface, a state-sharing hazard, and a source of subtle tenant-isolation bugs — all at once. The good news is that this release makes each of those three jobs easier.