PgBouncer vs RDS Proxy vs pgpool-II

This guide is part of Pool Architecture & Algorithm Fundamentals. It compares the three connection proxies most teams evaluate for PostgreSQL: PgBouncer, AWS RDS Proxy, and pgpool-II. The three are not interchangeable. They differ in pooling granularity, multiplexing model, load balancing, failover behavior, prepared-statement handling, and operational ownership. Picking the wrong one bolts a layer onto your stack that either fails to relieve connection pressure or actively breaks application semantics.

The decision usually reduces to three questions. How aggressively do you need to multiplex many client connections onto few backend connections? Do you need read/write splitting and automatic failover at the proxy layer? And who do you want to operate the proxy — your team, or a managed control plane? Each proxy optimizes a different point in that space.

Key operational takeaways:

  • PgBouncer is a pure connection multiplexer. Its pool_mode = transaction setting is the workhorse for collapsing thousands of clients onto tens of backend connections at sub-millisecond overhead.
  • RDS Proxy is managed PgBouncer-like multiplexing with IAM authentication, Secrets Manager integration, and automatic failover — but it pins sessions on stateful operations, eroding the multiplexing ratio.
  • pgpool-II is the only one of the three that does query-aware load balancing, read/write splitting across replicas, and statement-level replication. That power comes with the highest operational and latency cost.
  • Prepared statements are the recurring failure mode. Transaction-pooling proxies break server-side prepared statements unless the driver or proxy handles them explicitly.
  • Latency overhead ranks PgBouncer (lowest) < RDS Proxy < pgpool-II (highest). Operational overhead ranks RDS Proxy (lowest, managed) < PgBouncer < pgpool-II.
Connection proxy comparison matrix Three PostgreSQL proxies scored across multiplexing, load balancing, failover, managed integration, and latency overhead. PgBouncer self-managed RDS Proxy AWS managed pgpool-II self-managed Multiplexing Excellent (txn) Good, pins Moderate RW split / LB None Reader endpoint Query-aware Failover External (DNS) Automatic Built-in, complex IAM / managed DIY Native IAM DIY Added latency <1 ms 1–5 ms 2–8 ms Ops overhead Medium Low (managed) High Green = strength, amber = caveat, red = absent or costly.
Each proxy optimizes a different corner of the multiplexing / routing / managed-ops space.

Foundational mechanics

All three sit between application clients and PostgreSQL backends, but they intercept the protocol at different depths.

PgBouncer is a single-threaded, event-driven multiplexer written in C. It speaks the PostgreSQL wire protocol just far enough to know transaction boundaries and route bytes. It does not parse SQL. Its entire value is collapsing a large set of client connections onto a small set of long-lived server connections. The granularity of that collapse is governed by pool_mode, which is the most consequential setting in the system — covered in depth in PgBouncer Transaction vs Statement Pooling. In transaction mode a server connection is borrowed at BEGIN and returned at COMMIT/ROLLBACK; in session mode it is held for the client’s whole session, which gives almost no multiplexing benefit; in statement mode it is returned after every statement.

RDS Proxy is AWS’s managed equivalent for RDS and Aurora. Architecturally it behaves like a transaction-pooling multiplexer, but you never see a config file. You attach it to a database, point it at Secrets Manager for credentials, and AWS runs the fleet behind a single endpoint. Its differentiators are operational: native IAM authentication, automatic credential rotation, and failover that completes faster than DNS-based reconnection because the proxy holds the client connection open while it swaps the backend. The trade-off is session pinning — when a client does something stateful that the proxy cannot safely multiplex (a session-level SET, an advisory lock, a temp table, certain prepared-statement patterns), RDS Proxy pins that client to one backend connection for the rest of the session, defeating multiplexing for that client. The full behavior is documented in AWS RDS Proxy Connection Pooling.

pgpool-II is a heavier middleware that does parse SQL. That parsing unlocks capabilities the other two lack: it can route SELECT statements to read replicas while sending writes to the primary (load balancing and read/write split), maintain a query result cache, and even perform statement-level replication. It also bundles connection pooling, health checking, and a watchdog for high availability. The cost of parsing every query is real latency and substantial configuration surface area.

The multiplexing model is where these architectures diverge most sharply. PgBouncer’s transaction mode is the cleanest: a backend is borrowed at BEGIN and returned at COMMIT, so a client that is idle between transactions holds zero backend resources. This is why a few dozen backends can serve thousands of clients. RDS Proxy implements the same idea but layers a safety net on top — when it detects an operation it cannot safely hand to a shared backend, it silently switches that client to a dedicated connection (pinning) rather than risk state corruption. The result is correct but, under heavy stateful usage, indistinguishable from no pooling at all. pgpool-II’s pooling is per child process: each of num_init_children worker processes caches up to max_pool backend connections, so its effective concurrency is the product of the two, and its pooling granularity is coarser than PgBouncer’s transaction boundary.

Load balancing is the dimension only pgpool-II owns natively. PgBouncer has no concept of read versus write; every connection goes to the single backend you configured, and routing reads to replicas is the application’s job. RDS Proxy is a middle ground: Aurora exposes separate reader and writer endpoints, and you place a proxy in front of each, but the proxy itself does not parse a SELECT to decide where it belongs — the application picks the endpoint. pgpool-II inspects the statement, recognizes a read, and dispatches it to a weighted replica automatically, which is powerful and also the source of its most dangerous failure mode: routing a query that should have been consistent to a lagging replica.

Precision sizing & timeout orchestration

The multiplexing math is the same for all three: a small backend pool absorbs a large client population because most clients are idle between transactions at any instant. Size the backend pool from concurrency, not from client count.

Dimension PgBouncer RDS Proxy pgpool-II
Client-facing limit max_client_conn connection borrow timeout + soft limit num_init_children
Backend pool size default_pool_size per (db,user) MaxConnectionsPercent of DB max_connections max_pool × num_init_children
Idle reclaim server_idle_timeout IdleClientTimeout connection_life_time
Wait-for-connection query_wait_timeout borrow timeout (fixed ~120s) client_idle_limit
Backend recycle server_lifetime managed connection_life_time
Pooling granularity pool_mode (tunable) transaction-style, fixed connection_cache + mode

For PgBouncer, the canonical starting point is pool_mode = transaction, default_pool_size set to roughly the database’s CPU core count for write workloads, and max_client_conn set 10–50× higher. For RDS Proxy, MaxConnectionsPercent controls how much of the database’s max_connections the proxy may consume; leave headroom for direct connections and admin sessions. For pgpool-II, the effective backend ceiling is num_init_children × max_pool, which must stay under the database max_connections minus reserved superuser slots, or backends will refuse logins.

A subtle trap across all three: the application’s own pool (HikariCP, node-postgres, SQLAlchemy) sits in front of the proxy. Two pools in series multiply their failure modes. With a transaction-pooling proxy downstream, the application pool should be small and short-lived, because the proxy — not the driver — now owns physical connection reuse.

Failover orchestration is where RDS Proxy earns its keep and where the self-hosted options demand external machinery. A bare PostgreSQL connection through PgBouncer dies when the backend fails over; recovery depends on DNS or a virtual IP repointing, and the client must reconnect and replay. RDS Proxy keeps the client socket open across a database failover, swapping the backend underneath, so application connections see a brief stall rather than a hard error — this measurably reduces failover-induced error rates. pgpool-II includes its own watchdog and can promote a replica and rewrite its backend topology, but configuring its failover scripts, fencing, and split-brain protection is among the most error-prone parts of any pgpool deployment. The trade is explicit: managed failover for RDS Proxy, do-it-yourself orchestration for the others.

Latency is the inverse ranking of capability. PgBouncer adds well under a millisecond because it never parses SQL — it shuttles bytes. RDS Proxy adds a few milliseconds from the managed network hop and its safety logic. pgpool-II adds the most because every statement is parsed and routing-evaluated before it reaches a backend. For latency-sensitive OLTP, that per-query parse cost is the single strongest argument against pgpool-II when you do not actually need its routing.

Production configuration examples

A minimal PgBouncer setup for transaction pooling:

[databases]
app = host=10.0.1.20 port=5432 dbname=app

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 25
reserve_pool_size = 5
server_reset_query = DISCARD ALL
server_idle_timeout = 600
server_lifetime = 3600

pool_mode = transaction gives the multiplexing ratio; server_reset_query = DISCARD ALL scrubs session state between clients so no leakage occurs across the shared backend.

RDS Proxy is configured declaratively, not with a file. A Terraform sketch:

resource "aws_db_proxy" "app" {
  name                   = "app-proxy"
  engine_family          = "POSTGRESQL"
  require_tls            = true
  role_arn               = aws_iam_role.proxy.arn
  vpc_subnet_ids         = var.subnet_ids
  auth {
    auth_scheme = "SECRETS"
    iam_auth    = "REQUIRED"
    secret_arn  = aws_secretsmanager_secret.db.arn
  }
}

resource "aws_db_proxy_default_target_group" "app" {
  db_proxy_name = aws_db_proxy.app.name
  connection_pool_config {
    max_connections_percent      = 75
    max_idle_connections_percent = 50
    connection_borrow_timeout    = 120
  }
}

iam_auth = "REQUIRED" forces clients to authenticate with short-lived IAM tokens instead of static passwords — the main reason teams choose RDS Proxy over self-hosted PgBouncer.

A pgpool-II fragment enabling load balancing and read/write split:

backend_hostname0 = 'primary.internal'
backend_port0 = 5432
backend_weight0 = 1
backend_flag0 = 'ALWAYS_PRIMARY'

backend_hostname1 = 'replica.internal'
backend_port1 = 5432
backend_weight1 = 2

load_balance_mode = on
master_slave_mode = on
master_slave_sub_mode = 'stream'
num_init_children = 64
max_pool = 4

load_balance_mode = on with weighted backends sends most read traffic to the replica; master_slave_sub_mode = 'stream' ties pgpool’s replication awareness to PostgreSQL streaming replication so it never load-balances a query inside a write transaction.

Diagnostics & telemetry

Each proxy exposes a different observability surface.

PgBouncer ships a virtual admin database. Connect to it and run SHOW POOLS, SHOW CLIENTS, and SHOW SERVERS. The fields that matter are cl_waiting (clients blocked waiting for a backend) and sv_active versus default_pool_size. Persistent cl_waiting > 0 means the backend pool is the bottleneck. Wiring these into Prometheus is the subject of PgBouncer Metrics Monitoring.

RDS Proxy publishes CloudWatch metrics. The two to watch are DatabaseConnectionsBorrowLatency (rising latency signals backend exhaustion) and DatabaseConnectionsCurrentlySessionPinned (the single most important RDS Proxy metric — high pinning means you have lost multiplexing and should hunt down the stateful operations causing it).

pgpool-II exposes pcp_* admin commands and a SHOW POOL_NODES query that reports each backend’s role, weight, and replication lag. Lag on a read replica directly threatens correctness once load balancing is on, so it must be alerted.

Proxy Key endpoint Saturation signal Pinning/correctness signal
PgBouncer SHOW POOLS cl_waiting > 0 n/a (no pinning)
RDS Proxy CloudWatch BorrowLatency rising CurrentlySessionPinned high
pgpool-II SHOW POOL_NODES children all busy replica replication_lag high

Integration & proxy compatibility

The dominant compatibility issue across all three is prepared statements under transaction-level pooling. Because a server connection can change between two protocol messages from the same client, a server-side prepared statement created on backend A may not exist when the client’s next Execute lands on backend B, producing prepared statement "S_1" does not exist. PgBouncer 1.21+ added experimental prepared-statement tracking, but the historically safe answer is to disable server-side prepared statements in the driver (prepareThreshold=0 on JDBC, binary: false/simple protocol on node-postgres) or use pool_mode = session. The same constraint applies to RDS Proxy, where prepared-statement usage is one of the documented triggers for session pinning. pgpool-II handles this differently because it parses queries, but its own connection_cache and statement caching have separate caveats.

Layering also matters. You generally pick one multiplexing proxy. Putting RDS Proxy in front of a database that already sits behind PgBouncer doubles the pinning and timeout surface for no benefit. Use pgpool-II when you specifically need its query routing, and let pgpool own pooling rather than stacking PgBouncer beneath it. For serverless function fleets, the proxy choice and the in-function pool size interact tightly — that decision is walked through in Choosing a Connection Proxy for Serverless Postgres.

Common failure patterns & remediation

Symptom Root cause Exact fix Validation command
prepared statement "S_1" does not exist Server-side prepared statements under transaction pooling Set prepareThreshold=0 (JDBC) or simple protocol, or pool_mode=session Re-run query; check PgBouncer SHOW SERVERS for stable assignment
RDS Proxy multiplexing collapsed Session pinning from SET, temp tables, advisory locks Remove session-level state; move SET to SET LOCAL inside txn CloudWatch DatabaseConnectionsCurrentlySessionPinned drops
cl_waiting climbing in PgBouncer default_pool_size below concurrency demand Raise default_pool_size; verify DB max_connections headroom SHOW POOLS shows cl_waiting = 0
pgpool routes write to replica Query not recognized as write; load balancing too aggressive Wrap in explicit transaction; tune black_function_list SHOW POOL_NODES + check primary logs for the write
Stale reads after write Replica lag with load balancing on Add delay_threshold; route read-after-write to primary SHOW POOL_NODES replication_lag under threshold
Backends refuse new logins num_init_children × max_pool exceeds DB max_connections Lower max_pool or raise DB max_connections SELECT count(*) FROM pg_stat_activity;

When to pick each

The clean decision rule is to start from the capability you cannot live without and let it eliminate the others.

If your hard requirement is maximum multiplexing at minimum latency, pick PgBouncer. Nothing collapses more clients onto fewer backends with less overhead, and pool_mode = transaction gives you a tunable knob the managed options hide. The price is that you own the process: you must run it redundantly so it is not a single point of failure, patch it, and wire its admin stats into monitoring. Teams on Kubernetes or bare instances, or on a cloud without an equivalent managed proxy, default here.

If you are on RDS or Aurora and value operational simplicity, IAM authentication, and resilient failover more than the last increment of multiplexing ratio, pick RDS Proxy. You trade some efficiency to pinning, but you delete an entire operational surface — no process to run, native short-lived-credential auth, and failover that hides backend swaps from clients. The one discipline it demands is keeping sessions stateless so pinning stays rare; if your workload is full of session SETs and advisory locks, RDS Proxy will quietly stop pooling and you will be paying for a proxy that no longer multiplexes.

If your hard requirement is query-aware read/write splitting across replicas — sending reads to replicas and writes to the primary automatically, without the application choosing endpoints — pgpool-II is the only one of the three that does it. Accept that you take on the most configuration, the most latency per query, and the correctness risk of load-balancing onto a lagging replica. If you do not need that routing, its weight is not worth it; a PgBouncer-plus-application-routing setup is simpler and faster.

A useful tiebreaker: if you find yourself wanting two of these proxies stacked, stop. Pick the single one whose primary capability you need and let it own pooling. Layering RDS Proxy over PgBouncer, or PgBouncer under pgpool, doubles the pinning, timeout, and failure surface for no gain.