- Book: Database Playbook: Choosing the Right Store for Every System You Build
- 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
PgBouncer in transaction mode silently breaks prepared statements. Most ORMs default to prepared statements without telling you. The result: a confused team staring at prepared statement "s1" does not exist, a partial outage, and a Slack thread that ends with "let's just turn off the pooler."
The pooler isn't the problem. The mode is. And in 2026, the choice between PgBouncer, RDS Proxy, and Supavisor isn't about throughput. It's about which Postgres features you give up to get that throughput.
Why pooling matters more in 2026 than 2020
The long-lived app server is mostly dead. You used to have one Rails process per box, holding 25 connections forever, and a single Postgres could host hundreds of those processes before the kernel started crying.
Then everyone moved. Lambda. Cloud Run. Fly Machines. Render's autoscaler. Each new request spins a worker. Each worker opens a connection. Postgres still hates that. Every connection costs roughly 10MB of RAM and a backend process, and the max is max_connections (default 100, often raised to 200-500 in managed land). Hit that ceiling and you get FATAL: sorry, too many clients already, which is the most expensive five-word error in modern infra.
A pooler sits between your app and Postgres. It maintains a small set of real backend connections (say 50) and multiplexes thousands of client sessions across them. Whether that works depends on what your app expects from "a connection."
The three pooling modes, in order of safety
Every pooler offers some subset of these. They differ in how aggressively they reuse backend connections.
Session mode. A client gets a backend connection for the whole client session. Backend is released when the client disconnects. No multiplexing during the session. Temp tables, prepared statements, SET, LISTEN/NOTIFY, advisory locks all work because the same backend stays attached. Performance gain is modest: you avoid the TCP/auth handshake on reconnect, that's it.
Transaction mode. A backend is grabbed for one transaction and released back to the pool on COMMIT or ROLLBACK. This is where the real density comes from: 50 backends can serve 5000 clients if their transactions are short. The cost? Anything that lives outside a transaction breaks. Server-side prepared statements (PREPARE/EXECUTE) vanish when the backend rotates. SET outside SET LOCAL is gone. LISTEN/NOTIFY is fundamentally incompatible. Session-level advisory locks behave unpredictably.
Statement mode. Backend released after every single statement. Multi-statement transactions are flatly impossible. BEGIN/COMMIT is rejected. The only use case is short, idempotent, read-mostly traffic, and you have to mean it.
The default that's safe for any app, no questions asked, is session mode. The default that actually scales, with caveats you understand, is transaction mode. The default that breaks things you didn't know you used is also transaction mode. That's the whole tension.
PgBouncer: the lightweight veteran
PgBouncer is a small, single-process C daemon. It's been the reference Postgres pooler since 2007 and it still wins on RAM efficiency. A PgBouncer instance handling 10,000 client connections fits comfortably in 50-100MB.
A working pgbouncer.ini for transaction mode looks like this:
[databases]
orders = host=db.internal port=5432 dbname=orders
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 3
max_client_conn = 2000
# this one matters more than people think
server_idle_timeout = 600
server_lifetime = 3600
# PgBouncer 1.21+ fixes the prepared-statement disaster
max_prepared_statements = 200
The default_pool_size = 25 line is where teams get the math wrong. That's per (database, user) pair, not total. If you have 4 users connecting to 2 databases, you've authorised 200 backend connections, not 25.
Session mode swaps two lines:
pool_mode = session
default_pool_size = 50
# max_prepared_statements does nothing here in session mode
# already supports them natively. Remove it for clarity.
The big PgBouncer change of the last few years is max_prepared_statements. Starting in PgBouncer 1.21 (released 2023), the pooler tracks named prepared statements on each backend and re-prepares them transparently when the backend rotates. This finally made transaction mode safe for Hibernate, ActiveRecord, SQLAlchemy with prepared_statement_cache_size > 0, and node-postgres with Prepared Statement enabled. Before 1.21, you had to disable prepared statements in the driver, which silently disabled the query planner's ability to cache plans across calls.
PgBouncer's weakness is what it's not: it's not aware of replicas, it doesn't do IAM auth, it doesn't autoscale, it doesn't survive its own process death without help. You run it yourself (sidecar, daemonset, EC2) and you handle HA. That's fine for a team that already runs Postgres on their own. It's a paper cut for teams on managed databases.
RDS Proxy: the AWS-native answer
RDS Proxy is AWS's managed pooler. You point it at an RDS or Aurora cluster, attach an IAM policy, and you get a pooled endpoint that survives failover.
It runs only in transaction mode. There is no session mode option. So everything in the transaction-mode hazard list above applies. But RDS Proxy has its own emulation for prepared statements and SET that's been improving since 2022.
The reason teams pick it anyway is auth. RDS Proxy lets your app authenticate with IAM credentials instead of a password:
import boto3
import psycopg
from urllib.parse import quote
session = boto3.Session()
client = session.client("rds")
def get_token():
return client.generate_db_auth_token(
DBHostname="orders-proxy.proxy-abc.us-east-1.rds.amazonaws.com",
Port=5432,
DBUsername="app_user",
Region="us-east-1",
)
token = get_token() # 15-minute lifetime
dsn = (
f"host=orders-proxy.proxy-abc.us-east-1.rds.amazonaws.com "
f"port=5432 dbname=orders user=app_user "
f"password={quote(token)} sslmode=require"
)
conn = psycopg.connect(dsn)
The token is short-lived. Rotation is automatic. No long-lived password in your env, no Vault sidecar to maintain, no Kubernetes secret to leak. On a regulated workload (PCI, HIPAA, SOC 2 with auditors who actually read your secret-rotation docs), that alone is worth the cost.
The cost is real. RDS Proxy adds about 5ms of latency per query in the same VPC, charges per vCPU-hour of the underlying DB, and bills regardless of throughput. For a database that handles 50 connections from 3 EC2 instances, it's a 2x cost increase for a benefit you don't need. For a Lambda fleet handling 10k concurrent invocations against a db.t4g.large, it's the only thing that keeps the database alive.
One gotcha that has bitten more than one team: RDS Proxy has its own concept of "pinning." When your client uses a feature it can't multiplex (temp tables, prepared statements outside its emulation, certain session-level settings), it sticks that backend connection to the client for the rest of the session, defeating the pool. AWS's CloudWatch metric DatabaseConnectionsCurrentlySessionPinned is the one to watch. If it climbs, your throughput cliff is approaching.
Supabase Supavisor: built for serverless
Supavisor is the new entrant. Supabase wrote it in Elixir on the BEAM VM, open-sourced it in 2023, and it's been the default pooler on Supabase since early 2024. It's also runnable standalone against any Postgres.
The pitch is multi-tenant. A single Supavisor cluster can pool thousands of databases, route them by tenant ID extracted from the connection string, and survive the loss of any individual node because the BEAM is good at that. PgBouncer wasn't built for thousands of [databases] entries. Supavisor was.
A minimal Supavisor config (config.exs snippet):
config :supavisor, Supavisor.Repo,
hostname: "localhost",
port: 5432,
database: "supavisor",
pool_size: 10
config :supavisor,
proxy_port_session: 5432,
proxy_port_transaction: 6543,
metrics_port: 9090,
prepared_statements: true
The headline feature for 2026 apps is its handling of the extended query protocol. Supavisor speaks PostgreSQL's wire protocol natively, which means it can intercept Parse/Bind/Execute and emulate prepared statements in transaction mode without the client needing to opt in. That's a different mechanism than PgBouncer 1.21's named-statement tracking, and it works for unnamed prepared statements too. Which is what most modern drivers send by default.
Supavisor also exposes session mode and transaction mode on separate ports. Session mode goes through port 5432, transaction mode through 6543. Your app picks the mode by which port it dials, which makes the choice explicit instead of buried in config.
What you give up: Supavisor is heavier than PgBouncer (a node uses 200-400MB), it's newer, and the operator story outside Supabase's own cloud is rougher. If you're not on Supabase and you don't want to operate an Erlang cluster, PgBouncer 1.21 is the saner self-host.
Neon's built-in pooler and the "do you even need a pooler" question
Neon, the serverless Postgres provider, bundles a PgBouncer-derived pooler into every project. You get a -pooler hostname for free, no setup. Same for Supabase's hosted Postgres. Same, in effect, for managed Aurora if you turn on RDS Proxy.
For most teams in 2026 the question isn't "PgBouncer vs RDS Proxy vs Supavisor." It's "do I trust the pooler my provider gives me?" The honest answer is: usually yes, until you hit a feature it doesn't support. Then you run your own.
The three features that decide
Forget benchmarks for a moment. Three product features decide which pooler you can use.
1. Driver prepared-statement compatibility. What does your ORM do by default? Rails/ActiveRecord uses prepared statements unless you set prepared_statements: false. Hibernate uses them through PreparedStatement, always. SQLAlchemy's psycopg3 driver does, unless you pass prepare_threshold=None. Most TypeScript ORMs (Prisma, Drizzle with postgres.js) don't by default. Check yours. If it does and you want transaction-mode pooling, you need PgBouncer 1.21+, Supavisor, or RDS Proxy with its emulation enabled.
2. LISTEN/NOTIFY and session-level features. If any code path uses LISTEN, advisory locks held across statements, temp tables, or SET outside SET LOCAL, transaction mode is out. Period. The pooler will silently route subsequent queries to a different backend that doesn't have your subscription / lock / temp table. You won't get an error. You'll just get wrong behaviour. Either move that code to a direct connection (Postgres on port 5432, bypassing the pooler) or use session mode.
3. IAM / Vault credential rotation. If your compliance regime requires rotated credentials, RDS Proxy gives you that natively on AWS. Vault's database secrets engine can rotate Postgres roles for any pooler, but the operational burden is higher. PgBouncer's auth_query lets you delegate auth to Postgres, which composes with Vault but not as cleanly as RDS Proxy's IAM tokens.
Most decisions reduce to these three. Pick what your app uses and let that pick the pooler.
A pgbench run, three poolers, one chart
Benchmarks are easy to fake. The most-cited public number for this comparison is the Percona PgBouncer benchmark from 2024, which ran pgbench -c 200 -j 4 -T 60 -S (read-only, 200 clients) against PostgreSQL 16 with 100 backend connections.
Percona's published result for that workload: roughly 73,000 TPS with PgBouncer in transaction mode vs roughly 8,000 TPS direct-to-Postgres at the same client concurrency, on the same hardware. A roughly 9x throughput gain, because direct Postgres spent most of its time fighting max_connections. (Read the post for the full setup; Percona's numbers should be reproduced before you cite them for your own infrastructure.)
For RDS Proxy, AWS's own Database Blog benchmark shows roughly 1ms of added latency per query in steady state, with significant gains on connection-storm scenarios (the demo: 4000 clients connecting in burst, with vs without Proxy, where without-Proxy returned too many clients errors and with-Proxy held up).
Supavisor's public benchmark (admittedly self-published, take the framing accordingly) claims sustained 1M+ simultaneous client connections against a single Supavisor cluster, the marketing milestone they were aiming at. The throughput-per-connection number is in the same ballpark as PgBouncer; the headline is the connection density.
Don't ship based on someone else's benchmark. Run pgbench against your actual schema, your actual query mix, your actual concurrency. The numbers above are useful for sanity-checking, not for sizing.
The migration checklist
You're swapping in a pooler (or swapping between poolers). Run this list before you flip DNS.
- [ ] Audit your driver: does it use prepared statements by default? Test by tailing
pg_stat_statements. If you see queries with$1/$2placeholders, yes. Decide: keep them and pick a pooler that supports them, or disable them in the driver. - [ ] Grep your codebase for
LISTEN(note the space). Every match is a transaction-mode hazard. Route those code paths to a direct Postgres connection on port 5432. - [ ] Grep for
pg_advisory_lockwithout_xact_. Session-level advisory locks don't survive transaction-mode rotation. Switch topg_advisory_xact_lockor move to direct connection. - [ ] Search for
CREATE TEMP TABLEandCREATE TEMPORARY TABLE. Same hazard. - [ ] Search for
SET(notSET LOCAL). Every match is a session GUC that won't follow the backend rotation. - [ ] Check
default_pool_sizemath: per (database, user) pair, not total. Add them up against yourmax_connections. - [ ] Set
server_lifetimelower than your max idle backend RAM tolerance. Default 1 hour is reasonable; some teams run 10 minutes for tighter recycling. - [ ] Enable pool-level metrics. PgBouncer:
SHOW POOLS. RDS Proxy: CloudWatchDatabaseConnectionsCurrentlySessionPinned. Supavisor: Prometheus endpoint. - [ ] Set up a smoke test in CI that fails if any query uses a feature your pooler doesn't support. The cheap version: run your test suite through the pooler before merge.
- [ ] Have a rollback plan: keep the direct-Postgres endpoint reachable, DNS-pin in your config, and make sure flipping back is one config change away.
The pooler isn't free. It changes what Postgres looks like to your app: what features survive, what session state means, where errors land. Pick the one whose constraints match your app's constraints. That's the whole game.
What's the worst pooler-related outage you've seen? Was it the mode, the driver, or the math on default_pool_size? Curious which combination keeps biting teams in 2026.
If this was useful
This post is one slice of a larger decision tree: what to run in front of Postgres, when, and with which trade-offs. The Database Playbook goes through the rest. When to add a read replica, when partitioning beats sharding, which managed Postgres providers map to which workloads, and the chapter on connection management that this post pulls from. If you've ever picked a database by reflex, the book is the slow version of that decision.

Top comments (0)