PgBouncer vs PgCat vs RDS Proxy: PostgreSQL Connection Pooling Internals and Pitfalls
A familiar outage goes like this. A service scales from twenty pods to two hundred during a traffic spike, every pod opens its own pool of ten connections, and PostgreSQL suddenly faces two thousand client sessions against a max_connections of one hundred. New connections are refused, the autoscaler sees failing health checks and adds more pods, and the database that was healthy ten minutes ago is now buried under connection storms. The fix is rarely a bigger instance. It is a pooler in the middle, and choosing the wrong one, or the right one in the wrong mode, produces a subtler class of failures.
The PgBouncer vs PgCat question, with AWS RDS Proxy as the managed third option, is really three questions: how much SQL semantics each tool preserves when it multiplexes sessions, what each adds beyond pooling, and who operates it at three in the morning. This article answers them from the mechanism up, and it separates what the vendors document from what remains unverified.
What this covers: why Postgres connections are expensive, the three pooling modes, PgBouncer’s key settings, how PgCat and RDS Proxy differ, prepared statements and pinning, pool-sizing arithmetic, a runnable configuration and benchmark sketch, Kubernetes topologies, and a decision matrix.
Context and Background
PostgreSQL uses a process-per-connection architecture. The official documentation describes a “process per user” client/server model in which a supervisor, the postmaster, spawns a new backend process for every connection request, and the backends coordinate through shared memory and semaphores. That design is robust, because a crashed backend does not corrupt its neighbours’ private memory, and it is simple to reason about. It also means a connection is not a cheap socket. It is an operating-system process with its own private memory, catalog caches, and a slot in the shared lock and procarray structures that every transaction touches.
The defaults reflect this. The documentation lists max_connections as typically 100, with superuser_reserved_connections at 3 and reserved_connections at 0. Raising max_connections to several thousand is possible, but you pay in memory, in contention on shared structures, and in context switching when many backends are runnable at once. The practical consequence is that a database server has a sweet spot for concurrent active queries, roughly tied to its CPU cores and storage parallelism, and that number is far smaller than the number of client sessions a modern microservice fleet wants to hold open.
Application-side pools such as HikariCP, pgx pools, or SQLAlchemy’s QueuePool help, but they scale with the number of application instances. Multiply a per-instance pool by autoscaling replicas and the total is again unbounded. A server-side pooler fixes that by sitting between all clients and the database and enforcing one global budget of server connections. That is multiplexing: many client connections share few server connections.
Three implementations dominate practice. PgBouncer is the long-standing, single-purpose C pooler, and its documentation at pgbouncer.org is the primary reference used here. PgCat is a Rust pooler from the PostgresML project that layers load balancing, failover, and experimental sharding on top of pooling. Amazon RDS Proxy is a managed service for RDS and Aurora, with its own documented behaviour around multiplexing and a mechanism called pinning. Other poolers exist, including Supavisor and Odyssey, but this article does not make claims about them because they were not verified for this run.
Pooling also interacts with workloads beyond plain OLTP. Vector search, for example, holds connections open longer and runs heavier queries, which changes the sizing maths; see the discussion of pgvector versus a dedicated vector database. Ledger-style workloads care intensely about transaction boundaries, which is the unit transaction pooling relies on; the TigerBeetle versus Postgres ledger comparison explains why those boundaries matter. The PostgreSQL connection documentation, linked in Further Reading, is the authoritative description of backend behaviour.
Why Pooling Works: The Reference Architecture
Direct answer: A connection pooler holds a small, fixed set of authenticated server connections to PostgreSQL and leases them to a much larger population of client connections. In transaction mode a server connection is leased only for the duration of a transaction, so a few dozen backends can serve thousands of mostly idle clients without exhausting memory or max_connections.

Figure 1: A pooler terminates thousands of client connections and leases a small number of server connections, each backed by one Postgres process.
The diagram shows the essential trade. On the left, clients behave as if each owned a database connection. In the middle, the pooler holds the real server connections. On the right, each lease maps to one backend process sharing buffers and the write-ahead log. Everything interesting about the three products is about what the pooler must remember about each client in order to keep that illusion honest.
The cost of an idle backend
Most application connections are idle most of the time. A web request holds a connection for the milliseconds it takes to run two or three queries, then waits for the next request. Without a pooler, each idle client still owns a backend. That backend keeps its private caches, consumes a process slot, and shows up in every snapshot the server takes for visibility checks. The cost of idleness is not enormous per connection, but it multiplies, and the ceiling is hit long before CPU is saturated.
I will not quote a per-connection megabyte figure as fact, because it depends on version, extensions, catalog size, and what the session has touched. The honest approach is to measure: compare resident memory of idle backends on your own instance, and compare pg_stat_activity counts against throughput curves. What is robust across versions is the shape of the curve: throughput rises with concurrency until the server is saturated, then flattens and eventually degrades as contention grows. Capping active backends near that knee gives you the most throughput per core.
Little’s law sets the pool size
You can reason about pool size without folklore. Little’s law says that the average number of requests in a system equals arrival rate times average time in the system. Apply it to database work: if your service issues 2,000 transactions per second and each holds a server connection for 5 milliseconds on average, the average number of busy server connections is 2,000 times 0.005, which is 10. These are illustrative numbers, not measurements. A pool of 10 would be saturated on average, so you would size above it for variance, perhaps 20 to 30, and queue the rest briefly.
The lesson is counterintuitive for teams used to generous pools. Latency per transaction, not client count, determines how many server connections you need. Halve transaction time and you halve the connections needed. This is why slow queries and long idle-in-transaction sessions hurt poolers disproportionately: they hold scarce server connections hostage. A tool that multiplexes cannot rescue a workload whose transactions are slow.
Three pooling modes
All three tools expose the same conceptual ladder, though they differ in names and edge-case support. Session pooling assigns a server connection to a client for the whole lifetime of the client connection. It preserves every Postgres feature but gives little multiplexing, since an idle client still holds a server connection. It mainly helps by capping connection storms and absorbing reconnects. Transaction pooling assigns a server connection from BEGIN to COMMIT or ROLLBACK, which is where the large multiplexing win comes from. Statement pooling releases the server connection after each statement and forbids multi-statement transactions entirely, so it suits only narrow autocommit workloads.
PgBouncer’s documentation lists session as the default pool_mode, with transaction and statement as alternatives. PgCat’s example configuration uses transaction mode for its sharded example database and session mode for its simple one, and its README describes transaction mode as the default. RDS Proxy does not expose the same dial. It multiplexes at transaction granularity by default and falls back to pinning, which behaves like session pooling for the affected client, when it detects session-level state.

Figure 2: In transaction pooling, two clients take turns on the same server connection. Between their transactions the connection carries no client state, which is exactly what must be true for the illusion to hold.
The sequence diagram carries the central invariant. Between transactions the server connection must be indistinguishable from fresh, because the next lessee is a different client. Any statement that leaves residue on the connection, such as a session-level SET, a named prepared statement, a LISTEN registration, or a session advisory lock, breaks the invariant. The rest of this article is a catalogue of how each tool detects, forbids, emulates, or merely tolerates that residue.
PgBouncer: the reference implementation
PgBouncer is deliberately small. It speaks the PostgreSQL wire protocol on both sides, authenticates clients, keeps per-database and per-user pools, and moves bytes. It does not parse your SQL beyond what pooling requires, which is why it is fast and why its behaviour is predictable. Its current release as of this writing, according to the project changelog, is 1.26.0, dated 2026-09-23, and the changelog describes it as single-threaded. A single process therefore uses one core for its event loop, and horizontal scaling means running several processes, which is where the peering feature introduced in 1.19.0 helps, because it lets cancellation requests find the right process behind a load balancer.
The settings that matter most are few, and their documented defaults are worth memorising because the defaults are where incidents start.
| Setting | Documented default | What it controls |
|---|---|---|
pool_mode |
session |
When a server connection returns to the pool |
max_client_conn |
100 | Total client connections the pooler accepts |
default_pool_size |
20 | Server connections per user and database pair |
reserve_pool_size |
0 (disabled) | Extra server connections granted under pressure |
reserve_pool_timeout |
5.0 s | How long a client waits before the reserve is tapped |
max_db_connections |
0 (unlimited) | Cap on server connections per database across users |
query_wait_timeout |
120.0 s | How long a query may wait for a server connection |
max_prepared_statements |
200 | Prepared-statement tracking in transaction mode |
server_reset_query |
DISCARD ALL |
Cleanup when a connection returns, session mode only by default |
listen_port |
6432 | Where clients connect |
Two defaults deserve emphasis. First, default_pool_size applies per user and database pair, not globally. If you have four application roles each connecting to the same database, you can open up to 80 server connections with the default, and that product is how people accidentally exceed max_connections. max_db_connections is the safety valve that caps the total regardless of user. Second, query_wait_timeout of 120 seconds means a saturated pool makes clients hang for two minutes before failing, which is rarely what you want. A shorter value turns invisible queueing into a visible error that an upstream retry budget can handle.
The reserve pool is a pressure-release mechanism. When clients wait longer than reserve_pool_timeout, PgBouncer may open up to reserve_pool_size extra server connections to relieve them. It trades a brief overshoot of your intended ceiling for fewer timeouts, so budget for it: your true ceiling is default_pool_size plus reserve_pool_size per pool, not just the first number.
What transaction mode breaks
PgBouncer’s feature table marks several features as incompatible with transaction pooling: SET and RESET, LISTEN, WITH HOLD cursors, PREPARE and DEALLOCATE at the SQL level, temporary tables with PRESERVE ROWS or DELETE ROWS, the LOAD statement, and session-level advisory locks. These share a cause: each leaves state on the server connection that outlives the transaction.
The most common production victims deserve concrete treatment. Consider session advisory locks, popular for leader election and migration guards.
-- Broken under transaction pooling: lock and unlock may run on different backends
SELECT pg_advisory_lock(42);
-- ... other transactions, possibly on other server connections ...
SELECT pg_advisory_unlock(42); -- may report false; the lock is still held elsewhere
The lock belongs to the backend that took it. If the pooler hands your next transaction a different backend, the unlock is a no-op, and the original backend keeps the lock until it is recycled. The safe alternative is a transaction-scoped lock, which is released automatically at commit.
BEGIN;
SELECT pg_advisory_xact_lock(42); -- released at COMMIT or ROLLBACK
-- critical work inside the same transaction
COMMIT;
SET has a similar trap. SET search_path = tenant_a run outside a transaction sticks to a server connection and then leaks into whichever tenant’s request gets that connection next. In a multi-tenant schema-per-tenant design this is a cross-tenant data exposure, not a performance bug. The fix is SET LOCAL inside a transaction, which Postgres reverts at transaction end, or setting the parameter per role or per database so that it is not session state at all.
BEGIN;
SET LOCAL search_path = tenant_a, public;
SET LOCAL statement_timeout = '2s';
SELECT * FROM invoices WHERE id = $1;
COMMIT;
PgBouncer also has a middle path for client-supplied startup parameters. The track_extra_parameters setting, introduced in 1.20.0 according to the changelog, lets the pooler remember additional parameters set by a client and replay them, and the 1.26.0 notes say most default-reported parameters are now tracked automatically. That reduces the number of cases where SET quietly corrupts a neighbour, but it is a mitigation for specific parameters, not a general state-tracking engine. Treat arbitrary SET in transaction mode as unsafe unless you have tested the exact parameter.
LISTEN and NOTIFY deserve a separate note. A LISTEN registration lives on the backend, so a transaction-pooled client cannot reliably receive notifications. Services that depend on them usually hold a dedicated direct connection or a session-mode pool for the listener and leave the pooled path for ordinary queries. This split is a pattern, not a workaround: a database pooler should expose two doors, one multiplexed and one direct.
Prepared statements in transaction mode
Prepared statements are the largest compatibility story. Drivers prepare a named statement on first use, then send Bind and Execute messages for later calls to skip planning and parsing. The prepared statement lives on one backend. Behind a transaction-mode pooler, the next Execute may land on a backend that has never seen the statement, and the driver receives an error such as a missing prepared statement. For years the standard advice was to disable server-side prepared statements in the driver, for example with the prepareThreshold=0 option in the PostgreSQL JDBC driver, or statement_cache_size=0 in asyncpg, or prepared_statement_cache_queries=0 style settings in other ecosystems.
PgBouncer 1.21.0, released 2023-10-16 according to the changelog and nicknamed “the one with prepared statements,” changed that by tracking protocol-level named prepared statements. The pooler remembers each client’s statements by name and transparently re-prepares them on whichever server connection a transaction receives. The feature is enabled by a non-zero max_prepared_statements; the current documentation lists the default as 200, and the changelog says the default became 200 in 1.24.0 (earlier it was off). Version 1.22.0 added support for DEALLOCATE ALL and DISCARD ALL when the setting is non-zero, and 1.24.0 added prepared-statement counters to SHOW STATS.
Two limits remain. SQL-level PREPARE and EXECUTE statements, as opposed to protocol messages, are still on the documented incompatible list, because the pooler tracks protocol messages, not text you type in a query. And tracking costs memory and some CPU, which is why it is bounded: when a client exceeds the limit, PgBouncer evicts the least recently used statements, so a very large number of distinct statements per client will thrash.
PgCat’s position is murkier, and I could not fully resolve it from primary sources. The README features list says prepared statements are supported in session mode but not in transaction mode. Yet the sample pgcat.toml in the repository defines prepared_statements_cache_size = 500, with a comment marking its documentation as a to-do. Those two facts suggest support has been added or is in progress, but the README may lag the code. Treat transaction-mode prepared statements in PgCat as something to test against the exact version you plan to deploy, not as a given.
RDS Proxy takes yet another approach. Rather than re-prepare statements on the fly, it pins the session when it sees PREPARE, EXECUTE, DEALLOCATE, or DISCARD, and the documentation states that keeping prepared statements within a single transaction avoids pinning. The next section covers what pinning costs.

Figure 3: Any session-state change forces the proxy to pin the client to one server connection, which turns a multiplexed pool into one connection per pinned client.
PgCat: a pooler with opinions
PgCat is written in Rust on the Tokio runtime and is MIT licensed, according to the PostgresML documentation. It does pooling, and it adds features PgBouncer deliberately omits. The repository README lists load balancing and failover as stable, and sharding and mirroring as experimental.
Load balancing works by parsing queries. With query_parser_enabled on, as in the sample config, PgCat classifies each query as a read or a write, sends SELECT statements to replicas and everything else to the primary, and chooses among replicas with either a random strategy or one that picks the server with the fewest busy connections (loc, for least outstanding connections). This is a convenience, but it inherits the classic problems of read/write splitting. A SELECT that calls a function with side effects is still sent to a replica, and read-your-writes consistency is not guaranteed because replicas lag. A safer pattern is to let the application declare its intent, and PgCat supports explicit role selection through comments or session commands per its documentation.
Failover is health-check based. The README says unreachable servers are banned for a configurable period, 60 seconds by default, the primary is never banned, and if every server is banned the ban list clears. The ban_time = 60 in the sample config matches. The design is pragmatic and avoids a total outage from a flaky health check, but note that “the primary is never banned” means PgCat does not replace your cluster manager. It does not promote replicas; Patroni, a managed service, or a human still performs promotion, and PgCat simply routes to whoever answers.
Sharding is the headline feature and the one to treat most carefully. The README calls it experimental. Clients set a shard or sharding key through SQL commands or query comments, and automatic key extraction is also experimental. The hashing follows Postgres hash partitioning. This is useful for teams that already shard by tenant at the application level, and risky as a way to retrofit sharding onto a schema that was not designed for it: cross-shard queries, joins, and transactions are the hard part, and a pooler cannot solve them.
On maintenance, the README describes the project as actively developed and in search of contributors, and the PostgresML documentation page presents it as an open-source product. I could not retrieve commit history or release data in this run, so I cannot say how active development is today. Before adopting it for a critical path, check recent commits, open issues on prepared statements and authentication, and whether your required features are the stable ones.
RDS Proxy: managed multiplexing and its pinning tax
RDS Proxy is a managed service. You get an endpoint that keeps a pool of database connections for RDS and Aurora, authenticates clients through IAM or through credentials in Secrets Manager, and offers TLS. AWS documents two IAM modes: standard, where the client authenticates to the proxy with IAM and the proxy uses stored credentials toward the database, and end-to-end, where IAM is enforced all the way through. On failover, the documentation says the proxy keeps accepting connections at the same address, redirects to the new primary, and keeps most idle connections alive, cancelling only connections in the middle of a transaction or statement. That behaviour is the real selling point: applications avoid DNS propagation delays and connection storms during failover.
The cost is documented in the pinning page. By default the proxy reuses a database connection after each transaction. When it detects something that changes session state, it pins the client to one database connection until the client disconnects, and no other client can use that connection. For PostgreSQL the documented pinning triggers include SET commands, PREPARE, DISCARD, DEALLOCATE and EXECUTE used to manage prepared statements, creating temporary sequences, tables or views, declaring cursors, listening on a notification channel, loading a library such as auto_explain, calling sequence functions such as nextval and setval, using session advisory locks such as pg_advisory_lock, and using DISCARD ALL. Any statement larger than 16 KB also pins the session. Transaction-level advisory locks do not pin, and calling stored procedures and functions does not pin, though the documentation warns that the proxy cannot see session state changed inside them, so do not rely on it persisting.
The sequence functions item is the one that surprises people. An ORM that calls nextval to fetch an identifier, or a table with a serial default evaluated in a client-visible way, can pin a session without any obvious session-level statement in application code. The CloudWatch metric DatabaseConnectionsCurrentlySessionPinned shows how often it happens, and AWS advises that if most connections are pinned you should change the application or workload. The documentation also says session-pinning filters, which exist for MySQL-family engines, are not available for PostgreSQL.
AWS gives mitigations that read like a style guide. Move identical initialization SET statements into the proxy’s initialization query so they run when the proxy creates a connection rather than when the application issues them. Use transaction-level advisory locks. Do not use DISCARD ALL as a pool reset query. Keep prepared statements, temporary objects and cursors within a single transaction. These are the same rules you would follow for PgBouncer in transaction mode, which is the point: the semantics of multiplexing are the same everywhere, and only the enforcement differs.

Figure 4: Sidecar poolers keep the hop local but multiply server connections with pod count, while a central fleet enforces one global budget at the cost of an extra network hop.
Sizing, Configuration, and Measuring
This section turns the concepts into numbers, a configuration you can run, and a benchmark you can use to check your own assumptions.
Pool-size arithmetic you can defend
Start from the database, not the application. Suppose your Postgres primary has 16 vCPUs. A commonly used rule of thumb, which is a heuristic rather than a law, is that the useful number of concurrently active backends is a small multiple of core count, such as two to four times, and lower for CPU-bound analytical queries. Call the active-backend budget A. In this illustration A = 48. Reserve a few slots for superuser access, replication monitoring, and migrations: say 8. Then the pooler’s total server-connection ceiling is 48 - 8 = 40.
Now divide the ceiling across the pools that exist. If you run two PgBouncer instances for high availability, each can hold at most half: 20. If each instance serves three user and database pairs, default_pool_size per pair is not 20 but roughly 20 divided by 3, about 6, unless the workload is lopsided. The failure mode people hit is leaving default_pool_size at its default of 20 across several pairs and instances. Three pairs on two instances at the default of 20 each gives a worst-case 120 server connections, which exceeds the budget of 40 by a factor of three. max_db_connections exists to make that arithmetic enforceable in configuration rather than in memory.
Then check the required concurrency with Little’s law. If the busy-connection demand computed earlier is around 10 on average with a peak of three times that, a ceiling of 40 has headroom of more than 30 percent over peak. All numbers here are illustrative; substitute your measured transactions per second and mean transaction duration from pg_stat_statements or application metrics.
server_connection_ceiling = active_backend_budget - reserved_slots
per_pooler_ceiling = server_connection_ceiling / pooler_instances
default_pool_size (per DB-user pair) = per_pooler_ceiling / number_of_active_pairs
required_busy_connections = transactions_per_second * mean_transaction_seconds
sanity: peak_required_busy_connections < per_pooler_ceiling
The relationship between client connections and pool size is separate. max_client_conn can be large, in the thousands, because idle client connections to PgBouncer are cheap sockets, though each consumes file descriptors, so raise the process limit (ulimit -n) to match.
A runnable PgBouncer configuration
The following pgbouncer.ini implements the arithmetic above for a single database and a single application role. It uses transaction pooling, enables prepared-statement tracking, bounds total server connections, and fails fast instead of queueing for two minutes. It targets PgBouncer 1.24 or later, where max_prepared_statements defaults to 200 and is explicit here for clarity. Hostnames and paths are placeholders.
[databases]
appdb = host=10.0.0.10 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
admin_users = pgbouncer_admin
pool_mode = transaction
max_client_conn = 4000
default_pool_size = 20
reserve_pool_size = 5
reserve_pool_timeout = 3
max_db_connections = 40
max_prepared_statements = 200
query_wait_timeout = 10
server_idle_timeout = 600
server_lifetime = 3600
ignore_startup_parameters = extra_float_digits
A few lines need explaining. auth_type = scram-sha-256 matches modern Postgres defaults; PgBouncer has supported SCRAM since 1.11.0, and the auth_file holds a SCRAM verifier for each user. max_db_connections = 40 is the hard ceiling from the arithmetic, so even the reserve pool cannot push the total past what the server can absorb. query_wait_timeout = 10 makes a saturated pool return an error in ten seconds so that callers can shed load. ignore_startup_parameters = extra_float_digits handles a startup parameter that some drivers send and PgBouncer would otherwise reject. I have not executed this exact file against a live server in this run, so run pgbouncer -v /etc/pgbouncer/pgbouncer.ini or start it in a test environment and read the log before rolling it out.
Administration happens through a virtual database called pgbouncer, reachable with psql -p 6432 pgbouncer. Commands such as SHOW POOLS;, SHOW STATS;, and SHOW CLIENTS; expose the numbers you need.
-- psql -h 127.0.0.1 -p 6432 -U pgbouncer_admin pgbouncer
SHOW POOLS; -- cl_active, cl_waiting, sv_active, sv_idle, maxwait per pool
SHOW STATS; -- avg_xact_time, avg_query_time, avg_wait_time per database
SHOW CONFIG;
The two columns to watch are cl_waiting, which counts clients queued for a server connection, and maxwait, the age of the oldest waiter. A pool that is correctly sized shows cl_waiting near zero in steady state and brief spikes at peak. A persistent cl_waiting means either the pool is too small or, more often, that transactions are too long.
A pgbench sketch to test the claim yourself
Do not trust anyone’s benchmark of pooling, including mine, because the result depends on your transaction length, connection churn, and hardware. Instead, run a simple A/B test with pgbench, which ships with PostgreSQL. The test below compares direct connections against the pooler under a high client count, with -C forcing a new connection per transaction to expose connection-setup cost.
# Initialise a scale-50 dataset directly on Postgres
pgbench -i -s 50 -h 10.0.0.10 -p 5432 -U app appdb
# 1) Direct: 200 clients, 4 threads, 60 s, new connection per transaction
pgbench -c 200 -j 4 -T 60 -C -S -h 10.0.0.10 -p 5432 -U app appdb
# 2) Through PgBouncer in transaction mode with the same shape
pgbench -c 200 -j 4 -T 60 -C -S -h 127.0.0.1 -p 6432 -U app appdb
# 3) Without -C, many persistent clients: shows multiplexing rather than connect cost
pgbench -c 1000 -j 8 -T 60 -S -h 127.0.0.1 -p 6432 -U app appdb
The -S flag runs the built-in select-only script, which is read-only and short, the worst case for connection overhead and the best case for pooling. Run each variant several times, discard the first as warm-up, and record transactions per second and the reported latency average. To see the effect of prepared statements, add -M prepared and compare PgBouncer with max_prepared_statements = 0 against the default; with zero, the pgbench run is expected to error because the extended protocol names statements, which is itself a useful verification that your configuration is doing what you think. No numbers from this sketch are reported here because none were measured in this run, and any figure you find in a blog post, including a favourable one, is specific to the machine that produced it.
Methodology caveats are worth listing. Run the load generator on a separate host so it does not compete for CPU. Check that the single-threaded PgBouncer process is not itself saturating a core during test three, since a one-core ceiling will cap throughput and make the pooler look slower than the architecture really is. Keep Postgres settings identical across runs, and remember that select-only tests understate the effect of long transactions.
Comparing the Three: Operations, Failure Modes, and Fit
Where each tool sits
PgBouncer is the safe default for self-managed Postgres. It is mature, narrow in scope, and its behaviour is documented to the individual setting. Its weaknesses are the ones its design implies: single-threaded, no query routing, and no built-in awareness of replicas, though 1.24.0 added a load_balance_hosts option per the changelog, but that is host selection, not query routing. If you need read/write splitting, you place it elsewhere, in the application, in HAProxy, or in a smarter proxy.
PgCat is attractive when you want pooling, replica load balancing, and ban-based failover in one process. The price is a younger codebase with experimental sharding and mirroring, a prepared-statement story that you must verify per version, and a maintenance cadence I could not confirm. It is a reasonable choice when you have read replicas, want routing without an extra hop, and can invest in testing.
RDS Proxy is the lowest-operations option on AWS and the best at preserving connections through failover. It gives you IAM-integrated authentication and a managed endpoint. You pay for it as a service, you cannot tune the pooling algorithm itself, and its pinning rules mean ORMs and drivers that use session features may quietly lose most of the multiplexing benefit. It also locks you to AWS.
Failure modes that recur
The first is the thundering herd on restart. When a pooler or a database restarts, thousands of clients reconnect at once. PgBouncer absorbs client reconnects cheaply, but all clients then ask for server connections simultaneously. query_wait_timeout, jittered client retries, and the reserve pool soften this. Version 1.23.0 added rolling restarts where SIGTERM waits for clients to disconnect, and the changelog notes that SIGQUIT is needed for the old immediate behaviour, which matters for deployment tooling that sends SIGTERM and expects a prompt exit.
The second is idle-in-transaction leakage. An application that opens a transaction and then makes an HTTP call inside it holds a server connection for the duration of that call. Ten such requests per second with a one-second call hold ten connections doing nothing. Set idle_in_transaction_session_timeout on the Postgres side as a backstop, and fix the application pattern.
The third is cross-tenant state leakage through SET, described earlier. It is the most dangerous failure because it produces wrong data instead of errors. Audit for it with a test that runs two tenants through one forced single-server-connection pool and checks that parameters do not cross.
The fourth is silent loss of multiplexing through pinning on RDS Proxy. Alarm on DatabaseConnectionsCurrentlySessionPinned as a share of total connections, not only on error rates. A proxy with every connection pinned still works until the database’s own limit is reached, then fails exactly like having no proxy at all.
Topology: sidecar versus central
Figure 4 contrasts the two Kubernetes layouts. In the sidecar pattern each pod runs its own pooler container. The hop is loopback, latency is minimal, and a pooler failure takes down only that pod. But the pooler is now replicated with the application: two hundred pods mean two hundred pooler instances, each of which holds its own server connections. Total server connections equal pods times per-pod pool size, which is the same unbounded scaling problem you started with, only smaller. Sidecars therefore need tiny pools, such as 2 to 4 server connections each, and they do not provide global admission control.
In the central pattern a pooler fleet, usually a Deployment behind a Service, fronts the database. It enforces a real global ceiling and makes configuration changes a single rollout. The costs are an extra network hop, typically sub-millisecond within a zone but nonzero, and the need to run the pooler as highly available infrastructure. With a single-threaded PgBouncer you run several replicas and rely on a load balancer, so use peering (1.19.0 and later) to make query cancellation work across them. Most teams with more than a few dozen pods end up central, sometimes with a thin sidecar-free design and a per-zone pooler deployment for locality.
A third option, relevant to AWS shops, is to treat the managed service as the central tier and put no pooler of your own in the path. The trade is fewer moving parts against less control over pinning behaviour.
A decision matrix
| Criterion | PgBouncer | PgCat | RDS Proxy |
|---|---|---|---|
| Operating model | Self-hosted, any Postgres | Self-hosted, any Postgres | Managed, RDS and Aurora only |
| Pooling modes | Session, transaction, statement | Session, transaction | Transaction-style multiplexing with pinning |
| Prepared statements in transaction mode | Supported since 1.21.0 via max_prepared_statements |
README says no; sample config suggests cache support, verify per version | Pins the session unless kept within a transaction |
| Read replica routing | No built-in query routing | Query parser sends reads to replicas | Separate reader endpoints on Aurora |
| Failover behaviour | Reconnect to new target, you manage DNS or config | Ban-based health checks, no promotion | Keeps address and most idle connections through failover |
| Sharding | No | Experimental | No |
| Threading | Single-threaded process | Multithreaded Rust runtime | Managed, not exposed |
| Maturity | Longest track record | Younger, activity unverified here | Managed GA service |
| Best fit | Default choice for self-managed Postgres | Replica-heavy read traffic with testing budget | AWS teams wanting minimal operations |
Treat that table as a starting point. The reader endpoint claim for Aurora is general AWS behaviour that I did not re-verify in this run, and the multithreading claim for PgCat follows from its Tokio runtime rather than an explicit documentation statement.
Trade-offs, Gotchas, and What Goes Wrong
Pooling is not free, and the honest summary is that it moves complexity rather than deleting it. The pooler becomes a new critical component with its own capacity ceiling, its own failure modes, and its own configuration surface. A single-threaded PgBouncer process can saturate one core under heavy TLS termination or large result sets, and the symptom looks like database slowness even though Postgres is idle. Check pooler CPU before blaming the server.
Transaction pooling changes the contract between application and database. Session features stop being safe, and the failures are often silent. A forgotten SET is a data-correctness issue in multi-tenant systems, a session advisory lock becomes a lock that is never released, and a LISTEN consumer silently stops receiving events. None of these produce an error at deploy time, so the right defence is a test suite that runs against the pooled endpoint in transaction mode, not the direct one. If CI talks to Postgres directly and production talks to the pooler, you have a gap precisely where the bugs live.
Anti-patterns to avoid:
- Setting
max_client_connhigh anddefault_pool_sizehigh together. The first is cheap, the second is not. A large client limit with a small pool is the point of the design. - Using one pool for batch and interactive traffic. A long batch transaction holds a connection for minutes. Give batch its own user and pool so it cannot starve the interactive path.
- Relying on
DISCARD ALLas a cure-all. PgBouncer runsserver_reset_queryby default only in session mode. In transaction mode the pooler assumes clients leave no state, andDISCARD ALLis a pin trigger on RDS Proxy. - Retrying instantly on pool timeouts. Immediate retries from thousands of clients turn a saturated pool into a stampede. Use exponential backoff with jitter and a retry budget.
- Treating a pooler as a substitute for query tuning. If your mean transaction time doubles, so does the pool size you need.
Edge cases cluster around the protocol. Large statements over 16 KB pin sessions on RDS Proxy, which matters for bulk INSERT ... VALUES with thousands of rows, and COPY needs special handling in every pooler; PgBouncer 1.22.1 fixed issues with COPY FROM STDIN per its changelog. Cancellation requests need to reach the right server connection, which is why PgBouncer peering exists. Security matters too: PgBouncer 1.24.1 fixed CVE-2025-2291, where VALID UNTIL was not checked by auth_query, and 1.25.1 and 1.25.2 contained security and bug fixes, so keep the pooler patched like any internet-adjacent service.
Finally, remember what pooling does not fix. It does not raise write throughput, shorten lock waits, or make a bad query fast. It protects the server from connection volume and smooths bursts. If the root problem is lock contention or sequential scans, a pooler will merely queue the pain more politely.
Practical Recommendations
For a self-managed Postgres cluster, start with PgBouncer in transaction mode, a global server-connection ceiling enforced with max_db_connections, and a short query_wait_timeout. It is the lowest-risk path and the one with the most operational folklore. Add max_prepared_statements on version 1.21 or later so that modern drivers keep their prepared-statement performance, and still test your driver, because the feature covers protocol-level statements only.
Choose PgCat when routing reads to replicas inside the pooler is worth owning an additional moving part, and when you are willing to pin a version, read its issue tracker, and test prepared statements and failover on your own stack. Choose RDS Proxy when you run on RDS or Aurora, value failover behaviour and IAM integration, and can keep the workload free of pinning triggers. In every case, put a second door in front of the database for the things that cannot be pooled: a direct connection or session-mode pool for migrations, LISTEN consumers, and administrative sessions.
A short checklist you can apply this week:
- Compute your ceiling: active-backend budget minus reserved slots, divided by pooler instances, divided by active user and database pairs.
- Set
max_db_connections(or the equivalent) to enforce it, and alarm oncl_waitingandmaxwait, or onDatabaseConnectionsCurrentlySessionPinnedfor RDS Proxy. - Replace session-level
SETwithSET LOCALinside transactions, and session advisory locks with transaction-level ones. - Run your test suite against the pooled endpoint in transaction mode.
- Set
idle_in_transaction_session_timeouton the server as a backstop. - Give batch jobs, migrations and listeners their own pools or direct connections.
- Pin pooler versions and subscribe to release notes and security advisories.
- Load test with
pgbenchshaped like your real transaction mix before trusting any pool size, including the illustrative numbers in this article.
Frequently Asked Questions
What is the difference between session pooling and transaction pooling?
In session pooling a client keeps one server connection for its whole connection lifetime, so every Postgres feature works but multiplexing is minimal. In transaction pooling the server connection is leased only from BEGIN to COMMIT or ROLLBACK, which allows many more clients than backends. The price is that session-level state such as SET, LISTEN, temporary tables, and session advisory locks is unsafe, because the next transaction may run on a different backend.
Does PgBouncer support prepared statements in transaction mode?
Yes, since PgBouncer 1.21.0, released on 2023-10-16, for protocol-level named prepared statements. You enable tracking with a non-zero max_prepared_statements, which defaults to 200 in current versions. PgBouncer re-prepares statements on whichever server connection a transaction receives. SQL-level PREPARE and EXECUTE text commands remain on the documented incompatible list, and each client’s tracked statements are bounded by that limit, so test your driver’s behaviour.
Is PgCat better than PgBouncer?
It depends on what you need. PgCat adds replica load balancing, ban-based failover, and experimental sharding and mirroring, which PgBouncer does not offer. PgBouncer is more mature, narrower, and has documented behaviour for transaction-mode prepared statements. I could not verify PgCat’s current maintenance cadence in this run, and its README and sample config disagree about prepared statements, so test the exact version before choosing it for a critical path.
What is pinning in RDS Proxy and how do I avoid it?
Pinning means RDS Proxy keeps a client on one database connection until the client disconnects, because the session changed state, and no other client can use that connection. Documented triggers for PostgreSQL include SET, prepared-statement commands, temporary objects, cursors, LISTEN, sequence functions such as nextval, and session advisory locks. Avoid it by moving initial SET statements into the proxy’s initialization query and using transaction-level locks. Monitor DatabaseConnectionsCurrentlySessionPinned.
How big should my Postgres connection pool be?
Start from the database: estimate the number of concurrently active backends it can serve efficiently, subtract reserved slots, and divide across pooler instances and user and database pairs. Then verify with Little’s law: transactions per second multiplied by mean transaction seconds gives average busy connections. Smaller pools with short transactions often outperform large ones. Treat any rule of thumb, including the illustrative figures here, as a hypothesis to test with pgbench on your own hardware.
Do I still need an application-side pool if I use a pooler?
Usually yes, but a small one. An application-side pool avoids connection setup latency to the pooler and bounds concurrency per process, while the server-side pooler enforces the global budget across all processes. Keep application pools small, such as a handful of connections per process, and let the pooler multiplex. Avoid stacking large pools at both layers, because the larger one then hides queueing and delays the signal that you are saturated.
Further Reading
- pgvector vs a dedicated vector database for how heavier Postgres workloads change connection budgeting.
- TigerBeetle vs Postgres for financial ledgers for why transaction boundaries and isolation matter in high-integrity workloads.
- PgBouncer configuration reference, features and incompatibilities, and changelog.
- Avoiding pinning an RDS Proxy and the PostgreSQL connection settings documentation.
By Riju — about
