Postgres Connection Pooling Explained (2026): PgBouncer 1.26 Modes
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.
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_sizetimes its user/database pairs (or itsmax_db_connections), times the number of pooler instances superuser_reserved_connections(default 3) andreserved_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
Is Ansible Still Relevant in 2026? ansible-core 2.22 vs Terraform
Is Ansible still relevant in 2026? What ansible-core 2.22 and Ansible 14 changed, how Ansible compares with Terraform, cloud-init and Kubernetes, where configuration management still wins, and what Event-Driven Ansible and Lightspeed actually do.
11 minNov 10, 2026Apache Kafka in 2026: Kafka 4.4, No ZooKeeper, Queues and Diskless
Apache Kafka 4.4 explained for 2026: KRaft-only clusters since 4.0, production-ready queues (KIP-932), tiered storage, the accepted but unshipped diskless KIP-1150, WarpStream vs AutoMQ vs Redpanda, and when you do not need Kafka at all.
11 minNov 10, 2026How to Find Slow Queries in Postgres: pg_stat_statements and EXPLAIN (2026)
Step-by-step guide to finding and fixing slow PostgreSQL queries on Postgres 18: enable pg_stat_statements, rank queries by total time, read EXPLAIN ANALYZE plans and buffers, add the right index concurrently, fix bad estimates and log slow plans with auto_explain.
12 min