Postgres Connection Pooling Explained (2026): PgBouncer 1.26 Modes

Nov 10, 2026 · 11 min · Ajay Kumar

Every few months someone shows me a Postgres server with max_connections set to 5,000 and asks why it is slow while doing almost nothing. The answer is nearly always the same: the database is spending its effort on connections rather than queries, and what it needed was a pooler, not a bigger number. This post explains Postgres connection pooling as it stands in November 2026: why a Postgres connection is expensive, what PgBouncer's session, transaction and statement modes actually do, what changed when PgBouncer learned prepared statements in 1.21, and how RDS Proxy, Supavisor, PgDog and PgCat compare. If you are staring at FATAL: sorry, too many clients already right now, the companion guide, how to fix Postgres too many clients, is the hands-on version.

Why are Postgres connections expensive?

Postgres uses a process per connection. When a client connects, the postmaster forks a dedicated backend process that lives until the client disconnects, holding its own caches, its own memory contexts and a slot in shared memory. The documentation for max_connections says plainly that Postgres "sizes certain resources based directly on the value of max_connections", including shared memory, so even raising the limit costs something before anyone connects.

Memory is the cost people talk about, but it is not usually the one that hurts. Andres Freund, a Postgres committer, measured this in Analyzing the Limits of Connection Scalability in Postgres and found per-connection overhead below 2 MiB in many workloads once huge pages are configured. The bigger problem was CPU: with thousands of connections open, building a transaction snapshot meant walking every backend, and his CPU profile put the bottleneck squarely in GetSnapshotData(). His read-only pgbench run with a single active client fell from 33,773 transactions per second with no other connections to 6,049 with 10,000 mostly-idle connections alongside it.

One active client, more idle neighbours: transactions per second (PG 12) 0 idle connections 33,773 100 idle 29,074 1,000 idle 25,327 2,500 idle 19,752 5,000 idle 9,807 10,000 idle 6,049 Idle connections are not free: the same single client lost 82% of its throughput.
Read-only pgbench, one active connection, other connections sending SELECT 1 every 100 ms. Numbers from Andres Freund's connection scalability analysis (Citus Data blog, October 2020).

Postgres 14 shipped Freund's snapshot work (described in his follow-up post), so the cliff is less steep on current releases. The architecture did not change, though. Postgres 18 is still process per connection, and the Postgres 19 release candidate announced on 15 October 2026, with GA planned for 19 November, mentions no change to connection handling. Context switches, cache pressure and a hard ceiling on slots are still yours to manage, which is why the default of 100 connections is a sensible number and not an oversight.

What does a connection pooler actually do?

A pooler sits between clients and Postgres, accepts a large number of cheap client connections, and maps them onto a small number of real server connections. PgBouncer, the default choice for most of the last fifteen years, is a single-threaded event loop that its features page says needs about 2 kB per client connection. Ten thousand idle application connections become ten thousand small sockets on the pooler and perhaps 40 backends on the database.

The interesting question is when a server connection is handed back. PgBouncer gives you three answers, set by pool_mode:

  • Session: the server connection belongs to the client until it disconnects. This is the default and supports every Postgres feature, but it only helps with connection churn. A thousand clients that stay connected still need a thousand backends.
  • Transaction: the server connection is returned at the end of each transaction. This is the mode people mean when they say "use PgBouncer", because idle clients hold nothing.
  • Statement: returned after every statement, and multi-statement transactions are refused. It exists mostly for PL/Proxy-style autocommit workloads. I have never needed it.

Transaction mode is where the multiplexing comes from, and also where the surprises come from, because anything a client sets on "its" connection now leaks to whichever client gets that backend next.

What breaks in PgBouncer transaction mode?

PgBouncer publishes a feature map, and it is worth reading before you switch modes rather than after. In transaction mode these are marked as never working: SET/RESET, LISTEN, SQL-level PREPARE/DEALLOCATE, WITH HOLD cursors, PRESERVE/DELETE ROWS temp tables, LOAD, and session-level advisory locks. NOTIFY, ON COMMIT DROP temp tables and protocol-level prepared statements do work.

The SET row deserves care. PgBouncer tracks a set of startup parameters (client_encoding, DateStyle, TimeZone, application_name and a few others) and restores them per client. PgBouncer 1.26.0, released 23 September 2026, extended tracking to every parameter Postgres reports, which means search_path on Postgres 18 and default_transaction_read_only on 14 and later. Before that, a client running SET search_path in transaction mode could change the schema for unrelated clients that later used the same backend. That is the kind of bug that shows up as "the wrong tenant's table" rather than an error. 1.26.1 on 14 October fixed three CVEs and made ignore_startup_parameters = search_path work again for people who had relied on the old behaviour.

The practical rule I give teams: in transaction mode, set per-role defaults with ALTER ROLE ... SET, use SET LOCAL inside a transaction when you must, and use transaction-level advisory locks (pg_advisory_xact_lock) instead of session-level ones.

PgBouncer prepared statements: what changed in 1.21 and 1.24

For years the honest answer to "can I use prepared statements with PgBouncer transaction mode?" was no, and teams turned them off in the driver and paid for re-planning on every query. PgBouncer 1.21.0 in October 2023 added support for protocol-level named prepared statements, with the release notes claiming 15% to 250% higher throughput in synthetic benchmarks. It was off unless you set max_prepared_statements. 1.24.0 in January 2025 turned it on by default with a value of 200.

The mechanism, per the configuration docs, is rewriting. PgBouncer gives each unique query string an internal name like PGBOUNCER_123, rewrites the client's Parse/Bind/Execute messages to use it, and if the client lands on a backend that has not seen that statement yet, prepares it there transparently. The value is the size of an LRU cache per server connection, so set it above the number of distinct statements your application commonly uses.

Two limits matter. It only covers protocol-level statements, which is what most drivers send for parameterised queries; SQL PREPARE and EXECUTE are passed straight through and will still fail on a different backend. And 1.22.0 added DEALLOCATE ALL and DISCARD ALL support, but a plain DEALLOCATE name is still unsupported. If you see prepared statement "S_1" does not exist, you are on an old PgBouncer, have max_prepared_statements = 0, or are using SQL-level prepares.

Here is the shape of a transaction-mode config I would start from, pinned to the current release:

; pgbouncer.ini for PgBouncer 1.26.1 (released 2026-10-14)
[databases]
; clients connect to "appdb" on PgBouncer; it connects to the real server
appdb = host=10.0.1.20 port=5432 dbname=appdb

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction          ; return the backend after each transaction
max_client_conn = 2000           ; cheap: about 2 kB each, but raise ulimit -n
default_pool_size = 40           ; real backends per user/database pair (default 20)
max_db_connections = 45          ; hard cap per database across all users
reserve_pool_size = 5            ; extra backends when clients wait too long
max_prepared_statements = 200    ; default since 1.24; 0 disables support
query_wait_timeout = 120         ; default; disconnect clients queued this long
idle_transaction_timeout = 60    ; kill clients idle inside a transaction (default 0, off)

PgBouncer vs RDS Proxy vs Supavisor vs PgCat vs PgDog

PgBouncer is no longer the only option, and the alternatives differ less in pooling than in what else they do (multi-threading, sharding, IAM auth, multi-tenancy). The state of each as of this week:

Pooler Latest release License / model Notable for Source
PgBouncer 1.26.1, 14 Oct 2026 ISC, single-threaded C Default choice; prepared statements since 1.21; scale cores with so_reuseport GitHub releases
Amazon RDS Proxy managed $0.015 per vCPU-hour of the DB instance (us-east-1) IAM auth, failover; pins sessions on SET, PREPARE, temp tables, DISCARD ALL AWS pinning docs, AWS Price List
Supavisor v2.10.0, 6 Nov 2026 Apache-2.0, Elixir Multi-tenant; Supabase runs it with session mode on 5432 and transaction mode on 6543 GitHub, Supabase docs
PgDog v0.1.64, 5 Nov 2026 AGPL-3.0, Rust Pooling plus load balancing and sharding GitHub
PgCat 0.2.5, 11 Nov 2024 MIT, Rust Multi-threaded, sharding; no release in two years, last commit Feb 2025 GitHub

A few notes the table cannot hold. RDS Proxy is the easy answer on AWS, but read the pinning rules for PostgreSQL: any SET, SQL prepared statements, temp tables, cursors, LISTEN, session advisory locks and even a pool library that sends DISCARD ALL on release will pin the client to one backend, and a pinned proxy is just an expensive session pooler. Watch the DatabaseConnectionsCurrentlySessionPinned metric. Supabase's shared Supavisor pooler does not support prepared statements in transaction mode, while its dedicated pooler is PgBouncer and does. Neon also runs PgBouncer in transaction mode, with max_client_conn=10000 and a pool of 90% of the compute's max_connections.

PgCat's stall is worth pricing in. It was the most interesting multi-threaded alternative in 2023, and a pooler is not a component you want to own the fork of.

Serverless functions and AI agents: why connection counts exploded

The old failure was a Rails or Django fleet where each of 50 pods held a pool of 20 connections, so 1,000 backends sat idle. The newer one is worse because nobody chose the number. Every Lambda or Cloud Run instance opens its own connection, scale-out is measured in seconds, and a traffic spike becomes a connection spike that hits max_connections before CPU moves at all. I covered the platform side of that in serverless in 2026.

AI agents add a third pattern: many short-lived workers, each with a database tool, each opening a connection per tool call or holding one open across a slow model call. An agent that runs BEGIN, calls an LLM for 20 seconds, then commits is holding a backend idle in a transaction for those 20 seconds, which is the most expensive state a connection can be in because it also stops vacuum from removing dead rows that its snapshot might still need. I run Firecracker microVMs for agent workloads at PandaStack (my company), and the rule I would apply anywhere is the same: agents talk to a pooler in transaction mode, never directly to Postgres, and every agent role gets idle_in_transaction_session_timeout set on the role. The broader set of ways agents misbehave in production is in AI agent failures in production.

This is the part of the job that did not change with AI. Somebody still has to decide how many backends the database can afford and enforce it in one place.

How many connections should Postgres have?

Start from the database, not the clients. The widely quoted starting point, from the PostgreSQL wiki via HikariCP's pool sizing page, is connections = (core_count * 2) + effective_spindle_count, with physical cores and a spindle count near zero when the working set is cached. On an 8-core instance that suggests roughly 16 to 20 actively working backends. It is a starting point to load test around, not a law, but it explains why 40 well-used backends usually beat 400 busy ones.

Then add up everything that connects, because max_connections is shared:

  • each pooler instance's default_pool_size times its user/database pairs (or its max_db_connections), times the number of pooler instances
  • superuser_reserved_connections (default 3) and reserved_connections (Postgres 16 and later, default 0) for humans and monitoring
  • migrations, cron jobs, logical replication and anything that bypasses the pooler

Managed services set the ceiling for you. RDS for PostgreSQL defaults max_connections to LEAST({DBInstanceClassMemory/9531392}, 5000) according to the RDS quotas page, which is generous on large instances and is a limit, not a target. If you run Postgres on Kubernetes, CloudNativePG can manage PgBouncer for you as a Pooler resource; the CloudNativePG setup guide shows it, and running databases on Kubernetes covers whether you should.

FAQ

Do I need PgBouncer if my application already has a connection pool?

Usually yes, once you have more than a handful of application instances. An in-process pool (HikariCP, SQLAlchemy, pgx) bounds connections per process, but the total is pool size times instance count, and autoscaling changes that number without asking. PgBouncer gives you one place where the total is capped.

Should I use PgBouncer session mode or transaction mode?

Use transaction mode for web apps, serverless functions and agents, because it is the only mode where idle clients stop holding backends. Use session mode for clients that need session features such as LISTEN, session advisory locks or WITH HOLD cursors, ideally on a separate PgBouncer database entry with its own small pool.

Does PgBouncer support prepared statements in 2026?

Yes, protocol-level named prepared statements in transaction and statement mode since 1.21.0, enabled by default with max_prepared_statements = 200 since 1.24.0. SQL-level PREPARE and EXECUTE are still not supported across backends, and plain DEALLOCATE name is unsupported.

Is RDS Proxy better than PgBouncer?

It is easier on AWS and adds IAM authentication and faster failover, at $0.015 per vCPU-hour of the database instance in us-east-1. It multiplexes worse if your application uses SET, prepared statements or temp tables, because those pin sessions. Measure the pinned-connection metric before deciding it works for you.

This quarter, on any Postgres I am responsible for, I would put PgBouncer 1.26.1 in transaction mode in front of every application path, set idle_in_transaction_session_timeout on application and agent roles, drop max_connections back towards what the core count justifies, and keep reserved_connections free for the person who gets paged. Then I would grep the codebase for SET search_path before trusting the pooler with it. If you already have the error, the step-by-step fix for too many clients starts with the diagnostic queries.

Related: Should You Run Databases on Kubernetes in 2026?, How to Run Postgres on Kubernetes with CloudNativePG 1.30 (2026) and How to Run Zero-Downtime Postgres Migrations: Step by Step (2026).

Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related