pgpool-II vs PgBouncer for PostgreSQL Read Scaling
Problem statement: you need a PostgreSQL proxy in front of a primary and several replicas, and the two obvious candidates turn out to solve almost disjoint problems.
The comparison is frequently framed as “which proxy is better”, which has no answer, because pgpool-II load-balances reads and PgBouncer pools connections and neither does much of the other’s job. This page establishes which problem you actually have and what follows. It sits under choosing a read replica routing proxy.
Symptom Identification
The two problems have distinct signatures, and it is worth being sure which one you have.
A pooling problem looks like:
FATAL: sorry, too many clients alreadyorremaining connection slots are reserved.pg_stat_activityfull ofidleconnections that are not doing anything.- Connection counts scaling with application instances rather than with request rate.
- High CPU on the database attributable to connection setup and process management.
A routing problem looks like:
- The primary’s CPU dominated by
SELECT, while replicas are underused. - Adding replicas produced no relief on the primary.
- The application has a single connection string and no way to express “this is a read”.
They are independent: you can have both, either, or neither. Solving the wrong one produces no improvement and a new component to operate.
Root Cause Analysis
PgBouncer is a connection pooler and deliberately not a router. It sits between clients and one PostgreSQL target per database entry, multiplexing many client connections onto few server connections. In transaction pooling mode a server connection is returned to the pool at every transaction boundary, which is what lets thousands of clients share tens of backends. It does not parse SQL, so it cannot know whether a statement is a read.
That does not mean PgBouncer cannot support read scaling — it means the decision has to come from somewhere else. The standard pattern is two pool entries pointing at different hosts, with the application choosing which to connect to. Routing lives in the application; pooling lives in PgBouncer.
pgpool-II is a router that also pools. It parses each statement, classifies it, and load-balances reads across configured standbys according to weights. This is genuine read/write splitting with no application change, which is exactly what teams want when the application cannot be modified.
Its costs follow from the parsing. Every statement is interpreted, which adds latency proportional to complexity and brings the same classification edge cases every SQL-aware router has — data-modifying CTEs, SELECT ... FOR UPDATE, volatile functions, statements inside transactions. And pgpool-II’s process-per-connection model makes it a weaker pooler than PgBouncer at high client counts.
So the tools are complementary rather than competing. pgpool-II in front decides where; PgBouncer behind each node decides how many connections. The cost is two components in the path and two places to look during an incident.
Trade-off Comparison
| Capability | PgBouncer | pgpool-II |
|---|---|---|
| Connection pooling | Excellent — transaction mode compresses thousands of clients to tens of backends | Present, weaker — process-per-connection limits compression |
| SQL-aware read/write split | None — does not parse statements | Yes, with configurable classification |
| Load balancing across replicas | None | Yes, weighted per backend |
| Lag-aware routing | No | Yes, via delay_threshold |
| Automatic failover | No | Yes — overlaps with an HA cluster manager |
| Query caching | No | Yes, in-memory result cache |
| Latency added | Very low — forwards bytes | Low but higher; scales with statement complexity |
| Memory per client connection | Very small | Larger — a process per connection |
| Operational surface | Small, one config file | Large, many interacting features |
| Typical role | The pooling tier | The routing tier |
The last row is the practical summary. Choosing between them as alternatives is a category error in most deployments; choosing which decisions you want made where is the real question, and it usually resolves to PgBouncer as a pooler with routing decided either in the application or by a router in front.
Step-by-Step Resolution
Step 1 — Identify which problem you actually have
-- Pooling problem: are connections the constraint?
SELECT count(*) AS total,
count(*) FILTER (WHERE state = 'idle') AS idle,
current_setting('max_connections')::int AS max_conns,
round(100.0 * count(*) /
current_setting('max_connections')::int) AS pct_used
FROM pg_stat_activity WHERE backend_type = 'client backend';
-- Routing problem: is the primary doing read work?
SELECT round(100 * sum(total_exec_time) FILTER (WHERE query ~* '^\s*select')
/ nullif(sum(total_exec_time), 0)) AS pct_time_in_selects
FROM pg_stat_statements;Inline verification: high pct_used with a high idle count is a pooling problem; high pct_time_in_selects on the primary is a routing problem. They are independent.
Step 2 — Check whether the application can route itself
# If the application can express "this is a read", PgBouncer alone suffices
# and you avoid a SQL-parsing component entirely.
READ_DSN = "postgresql://app@pgbouncer:6432/app_read"
WRITE_DSN = "postgresql://app@pgbouncer:6432/app_write"Modern drivers and ORMs generally can — the patterns are in ORM middleware for automatic query routing. pgpool-II’s core value is for applications that cannot be changed.
Inline verification: a test connecting to each DSN reports the expected pg_is_in_recovery() value.
Step 3 — Configure the tool you chose
# pgbouncer.ini — two pool entries, two targets, no parsing.
[databases]
app_write = host=primary.internal port=5432 dbname=app pool_size=20
app_read = host=replica-1.internal port=5432 dbname=app pool_size=50
[pgbouncer]
pool_mode = transaction # what makes the compression possible
max_client_conn = 2000 # sized against the APP fleet
default_pool_size = 20 # sized against the DATABASE
server_lifetime = 300 # recycle so the fleet re-balances
server_check_query = SELECT 1
server_tls_sslmode = require# pgpool.conf — SQL-aware routing across a primary and two standbys.
backend_hostname0 = 'primary.internal'
backend_port0 = 5432
backend_weight0 = 0 # 0 = writes only; no reads land here
backend_flag0 = 'ALWAYS_PRIMARY'
backend_hostname1 = 'replica-1.internal'
backend_weight1 = 1
backend_hostname2 = 'replica-2.internal'
backend_weight2 = 1
load_balance_mode = on
master_slave_mode = on
master_slave_sub_mode = 'stream'
# Lag-aware: a standby further behind than this receives no reads.
sr_check_period = 5
delay_threshold = 10000000 # bytes of WAL, not seconds
# Leave failover to the HA cluster manager — two authorities is a split-brain risk.
failover_on_backend_error = offInline verification: SHOW POOLS; on PgBouncer shows sv_active well below cl_active; SHOW POOL_NODES; on pgpool-II shows a non-zero select_cnt on each standby.
Step 4 — If combining, put pgpool-II in front
app → pgpool-II (routes) → PgBouncer per node (pools) → PostgreSQL# pgpool points at the PgBouncers, not at the databases directly.
backend_hostname0 = 'pgb-primary.internal'
backend_port0 = 6432
backend_hostname1 = 'pgb-replica-1.internal'
backend_port1 = 6432Inline verification: on each database node, SELECT count(*) FROM pg_stat_activity shows a small, stable number — the PgBouncer pool size — rather than a number tracking application instances.
Step 5 — Verify routing and pooling separately
-- Routing: ask the server, not the proxy.
SELECT pg_is_in_recovery(); -- t on a replica
-- Pooling: compression ratio, measured at the database.
SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend';Inline verification: reads report t, writes report f, and backend connection counts are bounded by pool size rather than by client count.
Configuration Snippet
# pgbouncer.ini — a production baseline with the settings that matter most.
[databases]
app_write = host=primary.internal port=5432 dbname=app pool_size=20
app_read = host=replica-1.internal port=5432 dbname=app pool_size=50
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
# Transaction mode is the point of PgBouncer. Session mode gives up nearly
# all the compression and should only be used if the app needs session state
# — advisory locks, LISTEN/NOTIFY, or SET without SET LOCAL.
pool_mode = transaction
# Two ceilings with different jobs:
# max_client_conn — sized against the APPLICATION fleet
# default_pool_size — sized against the DATABASE's max_connections
max_client_conn = 2000
default_pool_size = 20
reserve_pool_size = 5
reserve_pool_timeout = 3
# Recycle connections so the read fleet re-balances rather than pinning.
server_lifetime = 300
server_idle_timeout = 60
# Fail fast rather than queueing forever — a fast failure feeds a circuit
# breaker; a slow queue starves one.
query_wait_timeout = 10
client_login_timeout = 5
server_tls_sslmode = requireThe two ceilings are the settings most often set to the same value by mistake, and they are answering different questions. max_client_conn exists to absorb far more client connections than the database could hold — that absorption is the product. default_pool_size must respect the database’s own limit, and critically it is per pool per PgBouncer instance: three PgBouncers each configured for 20 will open 60 backends against one replica, so the sum is what must fit under max_connections.
query_wait_timeout is the setting that decides how a saturated pool behaves. Left at zero, clients queue indefinitely and a slow replica turns into unbounded latency across the whole fleet. Set to a few seconds, an overloaded pool produces prompt errors that a circuit breaker can act on — the reasoning in avoiding connection exhaustion during replica failover.
Verification and Rollback
Confirm the pooler is compressing:
-- On the database. Should be bounded by pool size, not by client count.
SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend';# On PgBouncer: cl_active is clients, sv_active is backends. The ratio is
# the compression you are getting.
psql -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"Confirm pgpool-II is routing, not just forwarding:
psql -h pgpool -p 9999 -c "SHOW POOL_NODES;"
# select_cnt should be non-zero and roughly weighted across the standbys.
# All zeros on the standbys means load_balance_mode is off, or every
# statement is being classified as a write.Rollback. Both are removable from the path without data risk, and the order matters:
# PgBouncer: point the application back at the database directly.
# Ensure max_connections can absorb the uncompressed client count FIRST,
# or removing the pooler causes the outage it was preventing.
# pgpool-II: point the application at the primary directly. All reads move
# to the primary, so confirm it has the headroom before doing this.Removing a pooler is the more dangerous of the two, because the connection compression it provided disappears instantly and the database may not be able to accept the resulting client count. Removing a router is safer but concentrates all reads on the primary — verify headroom on whichever node inherits the load before either change.
Edge Cases and Gotchas
Transaction pooling breaks session-scoped features
In transaction mode a client’s statements may run on different backends between transactions, so anything relying on session state breaks: advisory locks, LISTEN/NOTIFY, SET without SET LOCAL, WITH HOLD cursors, and server-side prepared statements without max_prepared_statements configured. Audit for these before switching a pool to transaction mode — the failures are intermittent and confusing.
pgpool-II’s delay_threshold is in bytes, not seconds
It compares WAL byte positions, so its value does not correspond to a time budget in any stable way — the same byte lag is milliseconds on a quiet cluster and seconds during a write burst. If your freshness requirement is expressed in time, this is a poor fit and an application-level or agent-based check is more accurate. The distinction matters for anything relying on routing queries based on data freshness requirements.
Running two failover authorities is worse than one
pgpool-II can perform failover, and so can Patroni or repmgr. Enabling both means two independent systems can decide to promote, which is a split-brain risk rather than redundancy. Choose one authority; if an HA cluster manager is already in place, set failover_on_backend_error = off and let pgpool-II consume the topology rather than change it — the reasoning is in preventing split-brain during automated replica promotion.
FAQ
Can PgBouncer do read/write splitting?
Not by inspecting SQL — it does not parse statements. What it can do is expose separate pool names or ports pointing at different hosts, so the application chooses the read path by connecting to a different target. That is a real and widely used pattern, but the routing decision lives in the application, not in PgBouncer.
Does pgpool-II replace PgBouncer?
Only partially. pgpool-II includes connection pooling, but its per-process model is less effective at compressing thousands of client connections into a small number of backend ones than PgBouncer’s transaction pooling. Many deployments run pgpool-II for routing with PgBouncer behind it for pooling, precisely because each tool’s strength is the other’s weakness.
Is pgpool-II’s automatic failover worth using?
It works, and it overlaps with what a dedicated HA cluster manager does better. Running it alongside Patroni or repmgr means two systems can decide to promote, which is a split-brain risk rather than redundancy. Pick one authority for promotion; if you already run an HA cluster manager, disable pgpool-II’s failover.
Which adds more latency?
pgpool-II, because it parses every statement to decide where it goes, while PgBouncer forwards bytes without interpreting them. In absolute terms both are small — well under a millisecond for typical statements — but pgpool-II’s cost scales with statement complexity and its process-per-connection model makes it heavier under high connection counts.
Related
← Back to Choosing a Read Replica Routing Proxy
- PgBouncer vs ProxySQL for Read Replica Routing — the same split between pooling and SQL-aware routing, in the MySQL world.
- Configuring PgBouncer for Read-Only Connection Pools — the pool entries and modes in detail.
- Load Balancing Reads Across a Replica Pool — what a router does once it has decided a statement is a read.