Bekzod Erkinov
Listen to Article
Loading...Table of contents · 9 sections
Postgres Connection Pooling: PgBouncer vs Pgpool Deep Dive
Postgres uses a process-per-connection model. Every psql, every API worker, every cron job that opens a connection spawns a backend process that consumes ~10 MB of RAM, holds catalog caches, and contends for shared resources. A typical app server pool of 50 workers per pod across 20 pods = 1,000 backends — at which point you have spent more memory on idle connection state than on the buffer cache.
Connection poolers fix this by terminating client connections at a middleware that multiplexes them onto a much smaller pool of real backends. The two dominant choices are PgBouncer and Pgpool-II. They share a name and a category, but they solve fundamentally different problems. Choosing wrong will cost you either throughput, correctness, or a Sunday afternoon.
This tutorial walks the architecture of each, the operational characteristics that matter at scale, an explicit decision framework, and production-grade configurations for both.
1. The problem in one diagram
Without pooling:
app worker ──TCP──► postgres backend ──► shared buffers
app worker ──TCP──► postgres backend ──► shared buffers
app worker ──TCP──► postgres backend ──► shared buffers
... ^
(N clients = N backends, all the time)
With pooling:
app worker ──┐
app worker ──┼──► pooler ──► postgres backend (pool of M)
app worker ──┤ postgres backend
app worker ──┘ postgres backend
(N clients multiplexed onto M backends, M << N)
The interesting question is not whether you need a pooler — past ~200 concurrent connections you do — but how the pooler multiplexes, because that choice determines which Postgres features you can use.
2. PgBouncer: a lightweight, single-purpose multiplexer
PgBouncer is a single-binary, single-threaded (libevent-based) connection pooler written in C. It speaks the Postgres wire protocol on the front side and opens real Postgres connections on the back side. It does nothing else — no replication, no load balancing across replicas (in versions <1.21), no query routing, no failover. That focus is its main virtue: a PgBouncer process handles tens of thousands of client connections on a single core with sub-millisecond overhead.
2.1 The three pool modes
PgBouncer's behavior is governed by pool_mode, which determines when a server connection is returned to the pool:
| Mode | Connection assigned to client for | Safe Postgres features |
|---|---|---|
session |
Entire client session | Everything |
transaction |
Single transaction | Most — see exclusions |
statement |
Single statement | Read-only, no multi-statement transactions |
Session pooling is functionally equivalent to no pooling for correctness purposes; you get only the benefit of avoiding TCP/auth handshake overhead. Rarely worth deploying.
Transaction pooling is the mode 95% of production PgBouncer deployments use. The pooler holds a backend for the duration of BEGIN...COMMIT and returns it to the pool immediately on transaction end. This is where the real multiplexing happens: 5,000 clients can share 50 backends as long as their transactions are short.
But because backends are reassigned between transactions, anything that relies on session state breaks:
SET/SET LOCALoutside a transactionLISTEN/NOTIFYWITH HOLDcursors- Session-scoped advisory locks (
pg_advisory_lock) - Prepared statements (historically — see §2.2)
- Temporary tables (they persist across transactions but the next caller gets a different backend)
PREPARE TRANSACTION(two-phase commit prepare/commit on different backends)
Statement pooling returns the backend after every statement, which forbids multi-statement transactions entirely. It exists mostly for serverless / function-style workloads that only run autocommit selects.
2.2 Prepared statements: the historical landmine
Until PgBouncer 1.21 (October 2023), prepared statements in transaction pooling mode were broken: a client would PREPARE foo on backend A, then issue EXECUTE foo on backend B, which would not know foo. The standard mitigation was disabling prepared statements in the driver (prepareThreshold=0 in JDBC, prepare_threshold=None in asyncpg, prepared_statements=False in psycopg).
PgBouncer 1.21+ supports protocol-level prepared statements via:
max_prepared_statements = 200
The pooler intercepts Parse/Bind/Execute extended-protocol messages, transparently re-prepares them on whichever backend a client lands on, and maintains an LRU per backend. The client driver continues using prepared statements normally. Two caveats: SQL-level PREPARE name AS ... is still unsupported (only the extended protocol works), and the per-server LRU adds a small amount of memory and the occasional re-prepare round-trip on cold backends.
If you are starting fresh in 2026, turn this on. If you are running PgBouncer <1.21, upgrade — it is the single biggest correctness/performance win available.
2.3 Authentication
PgBouncer supports trust, plain, md5, scram-sha-256, cert, hba, and pam. The non-obvious operational point is the auth_user / auth_query pattern:
auth_user = pgbouncer_auth
auth_query = SELECT usename, passwd FROM pgbouncer.get_auth($1)
Rather than maintaining a static userlist.txt of every database user and their hashed password, PgBouncer logs in as auth_user and queries Postgres for the requesting user's stored password. Combined with a SECURITY DEFINER function that exposes only pg_shadow rows the auth user should see, this gives you "rotate a Postgres password and PgBouncer picks it up immediately."
SCRAM has a sharp edge: PgBouncer needs the original SCRAM secret from pg_shadow.passwd to authenticate clients to itself. If your clients use SCRAM and you want PgBouncer to enforce SCRAM (rather than downgrade to MD5), the secret stored in PgBouncer must match the one in Postgres, character for character. auth_query handles this automatically; manual userlist.txt maintenance does not.
2.4 A production pgbouncer.ini
[databases]
appdb = host=10.0.1.10 port=5432 dbname=appdb pool_size=40 reserve_pool=10
appdb_ro = host=10.0.1.20 port=5432 dbname=appdb pool_size=80
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
unix_socket_dir = /var/run/postgresql
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
auth_user = pgbouncer_auth
auth_query = SELECT usename, passwd FROM pgbouncer.get_auth($1)
admin_users = pgbouncer_admin
stats_users = pgbouncer_stats
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 3
max_db_connections = 200
max_user_connections = 200
server_lifetime = 3600
server_idle_timeout = 600
server_connect_timeout = 10
server_login_retry = 5
query_timeout = 0
query_wait_timeout = 30
client_idle_timeout = 0
client_login_timeout = 60
max_prepared_statements = 200
server_tls_sslmode = verify-full
server_tls_ca_file = /etc/pgbouncer/ca.crt
client_tls_sslmode = require
client_tls_cert_file = /etc/pgbouncer/pgbouncer.crt
client_tls_key_file = /etc/pgbouncer/pgbouncer.key
ignore_startup_parameters = extra_float_digits,application_name,search_path
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
stats_period = 60
pidfile = /var/run/pgbouncer/pgbouncer.pid
logfile = /var/log/pgbouncer/pgbouncer.log
A few choices worth justifying:
pool_size=40per database, notdefault_pool_size. The per-database override is what actually bounds backends on Postgres; the default is a safety net. Size it as(postgres max_connections - reserved_superuser - replication_slots) / number_of_poolers / number_of_databases. If your Postgresmax_connections=400and you run 4 PgBouncer pods, leave headroom:(400 - 20) / 4 / 2 ≈ 47, so 40 is conservative.query_wait_timeout=30. If a client cannot get a backend within 30s, fail fast. The alternative — queueing forever — turns a backend-saturation incident into a thundering-herd outage when the saturation clears.client_idle_timeout=0. Connection pools in modern app frameworks already manage idle eviction; double-eviction creates flapping.ignore_startup_parameterslists parameters PgBouncer would otherwise reject because they cannot be applied across a multiplexed pool.application_nameis special-cased and is tracked per session in recent versions.
2.5 Operating PgBouncer
The console is a fake Postgres database called pgbouncer:
psql -h 127.0.0.1 -p 6432 -U pgbouncer_admin pgbouncer
Useful commands inside it:
SHOW POOLS; -- per-pool active/waiting/idle counts
SHOW CLIENTS; -- every client connection and its state
SHOW SERVERS; -- every backend connection
SHOW STATS; -- query/transaction counts and latencies
SHOW MEM; -- internal memory usage
RELOAD; -- re-read config without dropping clients
PAUSE appdb; -- park clients, drain transactions (for maintenance)
RESUME appdb;
RECONNECT appdb; -- gracefully cycle backends (e.g. after a primary failover)
SHOW POOLS is the single most useful diagnostic. The cl_waiting column is your saturation signal: any non-zero value means clients are queued waiting for a backend. Sustained cl_waiting > 0 means either your queries are too slow or your pool_size is too small.
For metrics, the pgbouncer Prometheus exporter scrapes SHOW STATS and SHOW POOLS and emits standard counters. Alert on:
pgbouncer_pools_client_waiting_connections > 0for >2 minutes (saturation)pgbouncer_stats_avg_xact_time_secondsp99 doubling week-over-week (latency regression)pgbouncer_databases_current_connections / pgbouncer_databases_max_connections > 0.8(capacity)
3. Pgpool-II: a Postgres traffic manager
Pgpool-II is a much larger system. It is also a connection pooler, but its primary purpose is being a cluster-aware middleware: it does load balancing across primary and replicas, automatic query routing (read vs write), in-memory query result caching, watchdog-based HA for itself, and — controversially — automatic failover and online recovery of Postgres nodes.
A Pgpool process is multi-process (fork-per-connection-by-default in its native pool model, plus several auxiliary processes for health checks, watchdog, and recovery). It is a much heavier piece of software, and its connection-pool subsystem is not equivalent to PgBouncer's transaction pooler — this is the source of most confusion.
3.1 What Pgpool's "connection pooling" actually is
Pgpool's built-in pool is per-child-process. Each child handles one client at a time, and after the client disconnects, the child holds the backend open and reuses it if the next client requests the same (user, database) tuple. There is no transaction-level multiplexing. If you have 1,000 concurrent clients you have 1,000 Pgpool children and 1,000 Postgres backends — Pgpool has not reduced backend count, only saved you the TCP/auth handshake on reconnects.
This matters: if your goal is "I have 5,000 clients and 200 max backends," Pgpool alone does not solve it. The standard production pattern is Pgpool in front for routing + PgBouncer behind it for multiplexing, or PgBouncer in front of Pgpool. Both work; both have failure modes.
3.2 Load balancing and query routing
This is Pgpool's real value. With load_balance_mode = on and a list of backend nodes:
backend_hostname0 = 'primary.db.internal'
backend_port0 = 5432
backend_weight0 = 0
backend_flag0 = 'ALLOW_TO_FAILOVER'
backend_hostname1 = 'replica1.db.internal'
backend_port1 = 5432
backend_weight1 = 1
backend_flag1 = 'ALLOW_TO_FAILOVER'
backend_hostname2 = 'replica2.db.internal'
backend_port2 = 5432
backend_weight2 = 1
backend_flag2 = 'ALLOW_TO_FAILOVER'
Pgpool parses every incoming query and decides:
SELECT(not in a transaction, noFOR UPDATE, no functions writing data) → load-balanced to a replica by weightINSERT/UPDATE/DELETE→ primary- Transaction containing any write → entire transaction pinned to primary
- Function/CTE/DO block with writes → primary
This is genuinely useful and hard to replicate. PgBouncer cannot do it (it doesn't parse queries). Doing the same in your application means either a smart driver (some have read/write splitting, most don't) or every team writing their own router.
The trap is that the parser has to be conservative. A SELECT my_function() will go to the primary unless you explicitly mark my_function as read-only via white_function_list. A query in a transaction must go to one node, so if you BEGIN; SELECT ...; SELECT ...; COMMIT you have just pinned three SELECTs to the primary unnecessarily. Application code written without awareness of Pgpool's routing rules tends to send much more traffic to the primary than the team expects, because long-lived ORM "sessions" implicitly open transactions.
3.3 Streaming replication mode vs replication mode
Pgpool supports several cluster modes. The two that matter:
streaming_replication(modern, recommended): Postgres handles replication via its own streaming. Pgpool only routes queries. Failover is Pgpool detecting the primary is down and promoting a replica.native_replication(legacy): Pgpool itself fans every write out to every backend. This is statement-level replication and has all the correctness problems statement replication always has —now(),random(), sequences, etc. drift between nodes. Don't use this. Use Postgres streaming replication and let Pgpool route.
3.4 Watchdog and automatic failover
Pgpool ships its own HA mechanism (watchdog) that elects a leader Pgpool across multiple Pgpool nodes and floats a virtual IP. It also performs automatic Postgres failover: it detects a down primary via health checks, runs a failover_command (a shell script you provide that promotes the new primary, typically by writing a trigger file or calling pg_ctl promote), and updates its own routing.
This sounds great. In practice it is the most operationally dangerous feature of Pgpool:
- Split-brain: A network partition between Pgpool nodes can produce two Pgpools each convinced they are the leader, each promoting a different replica. You now have two primaries accepting writes that cannot be reconciled. Watchdog has quorum to mitigate this; quorum requires three Pgpool nodes minimum and correct configuration.
- Premature failover: A momentary blip in health-check connectivity can trigger a failover. The new primary catches up to wherever WAL streaming left off; transactions committed on the old primary but not yet streamed are lost.
health_check_timeoutand retries need to be sized very conservatively. - The
failover_commandruns as root (typically) and executes arbitrary scripts. Bugs here cause data loss. Test your script against a real failure scenario monthly.
If you are running Postgres in a managed environment (RDS, Aurora, Cloud SQL, Crunchy), the platform already handles failover and you should turn off Pgpool's failover entirely (failover_on_backend_error = off, no failover_command) and use Pgpool purely as a routing layer. If you are self-managing, strongly consider Patroni for cluster management instead, with Pgpool (or PgBouncer + HAProxy) only handling the data path.
3.5 In-memory query cache
Pgpool can cache the result rows of SELECT queries in shared memory or memcached, keyed by the query text. On a cache hit it returns rows without touching Postgres. Invalidation is by relation: any INSERT/UPDATE/DELETE/TRUNCATE against a table evicts all cached queries referencing it.
The performance numbers are impressive on read-heavy workloads, but two caveats apply:
- Coarse invalidation means a small write to a hot table evicts a large amount of cache. On write-heavy workloads the cache hit rate collapses.
- Anything non-deterministic in the query (
now(), parameterized queries with new bind values) is a cache miss. For a typical app where every query has a differentWHERE user_id = ?, the hit rate is near zero unless you cache aggregate queries explicitly.
Treat the cache as a targeted feature for specific known-hot read queries (reference-data lookups, leaderboards) rather than a global accelerator.
3.6 A production pgpool.conf
This is the routing-only configuration — Pgpool as a read/write splitter in front of a managed Postgres primary + replicas, with PgBouncer behind it for multiplexing.
# --- listen ---
listen_addresses = '*'
port = 9999
socket_dir = '/var/run/pgpool'
pcp_socket_dir = '/var/run/pgpool'
# --- backends ---
backend_hostname0 = 'pgbouncer-primary.svc'
backend_port0 = 6432
backend_weight0 = 0
backend_data_directory0 = '/data/primary'
backend_flag0 = 'ALLOW_TO_FAILOVER'
backend_application_name0 = 'primary'
backend_hostname1 = 'pgbouncer-replica1.svc'
backend_port1 = 6432
backend_weight1 = 1
backend_flag1 = 'ALLOW_TO_FAILOVER'
backend_hostname2 = 'pgbouncer-replica2.svc'
backend_port2 = 6432
backend_weight2 = 1
backend_flag2 = 'ALLOW_TO_FAILOVER'
# --- mode ---
backend_clustering_mode = 'streaming_replication'
load_balance_mode = on
master_slave_mode = on # legacy alias still required in older versions
statement_level_load_balance = off
disable_load_balance_on_write = 'transaction'
# --- pool ---
num_init_children = 200
max_pool = 1
child_life_time = 300
child_max_connections = 0
connection_life_time = 0
client_idle_limit = 0
# --- query routing ---
white_function_list = ''
black_function_list = 'currval,lastval,nextval,setval'
read_only_function_list = ''
write_function_list = ''
# --- replication delay ---
sr_check_period = 10
sr_check_user = 'pgpool_check'
sr_check_database = 'postgres'
delay_threshold = 10000000 # 10 MB of WAL lag → stop routing reads to that replica
# --- health checks ---
health_check_period = 10
health_check_timeout = 20
health_check_user = 'pgpool_check'
health_check_database = 'postgres'
health_check_max_retries = 3
health_check_retry_delay = 5
connect_timeout = 10000
# --- failover: OFF, the database platform handles it ---
failover_on_backend_error = off
failover_command = ''
failback_command = ''
follow_master_command = ''
# --- watchdog (only if running multiple pgpool nodes) ---
use_watchdog = on
delegate_IP = '' # using k8s service, not VIP
wd_hostname = 'pgpool-0.pgpool.svc'
wd_port = 9000
other_pgpool_hostname0 = 'pgpool-1.pgpool.svc'
other_pgpool_port0 = 9999
wd_port0 = 9000
other_pgpool_hostname1 = 'pgpool-2.pgpool.svc'
other_pgpool_port1 = 9999
wd_port1 = 9000
# --- query cache: off unless you have profiled a use case for it ---
memory_cache_enabled = off
# --- ssl ---
ssl = on
ssl_cert = '/etc/pgpool/server.crt'
ssl_key = '/etc/pgpool/server.key'
ssl_ca_cert = '/etc/pgpool/ca.crt'
# --- logging ---
log_destination = 'stderr'
log_min_messages = warning
log_connections = off
log_statement = off
log_per_node_statement = off
Key choices:
num_init_children = 200,max_pool = 1: Pgpool pre-forks 200 children, each maintaining one backend connection. Because backend connections terminate at PgBouncer (which multiplexes), 200 connections per Pgpool is comfortable. Without PgBouncer behind, you would neednum_init_children * max_pool * number_of_pgpool_nodesto fit under Postgresmax_connections— this is where the math goes badly wrong fast.disable_load_balance_on_write = 'transaction': Once a transaction has issued a write, all subsequent statements in that transaction (including SELECTs) go to the primary. Without this, you risk read-after-write inconsistency from streaming lag.delay_threshold = 10MB: A replica more than 10 MB of WAL behind the primary is removed from the read pool until it catches up.failover_on_backend_error = off: The platform (Patroni, RDS, Aurora) handles primary failover. Pgpool just discovers the new topology via DNS / service updates.
3.7 Operating Pgpool
The PCP interface is Pgpool's admin channel. Examples:
pcp_node_info -h pgpool -p 9898 -U pcpadmin -n 1
pcp_attach_node -h pgpool -p 9898 -U pcpadmin -n 1
pcp_detach_node -h pgpool -p 9898 -U pcpadmin -n 1
pcp_recovery_node -h pgpool -p 9898 -U pcpadmin -n 1
pcp_watchdog_info -h pgpool -p 9898 -U pcpadmin
For per-query diagnostics, log into the Pgpool port and use SHOW commands:
SHOW POOL_NODES; -- backend status, replication lag, load balance share
SHOW POOL_PROCESSES; -- child processes and their state
SHOW POOL_POOLS; -- per-child pool detail
SHOW POOL_BACKEND_STATS;
SHOW POOL_HEALTH_CHECK_STATS;
SHOW POOL_NODES is the equivalent of SHOW POOLS for PgBouncer — the first thing to run during an incident. Watch replication_delay and status columns.
The pgpool exporter for Prometheus is less mature than pgbouncer-exporter; many shops run a sidecar that polls SHOW POOL_NODES on a timer and emits node up/down and replication lag as gauges.
4. Head-to-head, by dimension
| Dimension | PgBouncer | Pgpool-II |
|---|---|---|
| Primary purpose | Connection multiplexing | Cluster routing + multiplexing + HA |
| Process model | Single-process, single-threaded, libevent | Multi-process, pre-forked children |
| Multiplexing | True transaction pooling: 5000 clients → 50 backends | Per-child pool: 1 client ↔ 1 backend |
| Memory footprint | ~5–20 MB | 100s of MB |
| Latency overhead | ~0.1–0.3 ms | ~1–3 ms (query parsing + routing) |
| Max realistic clients per instance | 10k+ | bounded by num_init_children × instances |
| Read/write splitting | No (1.21+ adds round-robin across replicas but no parsing) | Yes, parses every query |
| Replica load balancing | Per-database backend only (or via HAProxy in front) | Built-in, weighted, lag-aware |
| Automatic failover | No (use HAProxy/Patroni/cloud-native) | Yes (use with extreme care) |
| Query result cache | No | Yes (limited usefulness in practice) |
| Prepared statements (extended protocol) | Yes since 1.21 | Yes |
LISTEN/NOTIFY |
Only in session mode | Yes (per-child pool) |
| Temporary tables | Only in session mode | Yes |
| Configuration complexity | One ini file, ~30 lines | One conf file, hundreds of options |
| Operational risk | Low (boring, predictable) | High (failover, watchdog, query parser edge cases) |
| Where it sits well | Right in front of Postgres, always | In front of multiple Postgres nodes for routing |
5. How to choose
Skip the comparison matrix and answer these three questions in order:
1. Do you have more than ~200 concurrent client connections? If no, you do not strictly need a pooler. (You probably still want one for graceful restart behavior and to insulate Postgres from connection storms during deploys.)
2. Do you need read/write splitting across replicas at the proxy layer? If yes, your choices are Pgpool-II or a smart driver/app-level router. If you choose Pgpool, deploy PgBouncer behind it for actual multiplexing.
If no, use PgBouncer. Don't introduce Pgpool's complexity for features you won't use.
3. Are you on managed Postgres (RDS, Aurora, Cloud SQL, Crunchy, Supabase)? If yes, the platform handles failover and replica routing (often via separate reader/writer endpoints). PgBouncer is the right answer; Pgpool's HA features are wasted and its query parser becomes friction. Most managed platforms even offer PgBouncer as a built-in feature (RDS Proxy is a similar concept; Supabase ships PgBouncer; Aurora has its own multiplexer).
Default recommendation for 2026 greenfield: PgBouncer 1.21+ with transaction pooling and protocol-level prepared statements, two pools per database (one to the primary, one to a replica endpoint that the platform load-balances), pool_mode = transaction, sized so that pool_size × pgbouncer_instances < postgres max_connections - 30. Add Pgpool only if you have a concrete workload requirement (cross-replica routing without app changes) that justifies it.
6. Sizing the pool
This is where people most often get pooling wrong. The pool is not "however many connections feel right." It is a number that comes from your workload and your Postgres hardware.
Start with the formula attributed to the pgbouncer authors:
pool_size ≈ (num_cpus × 2) + effective_spindle_count
For modern SSD-backed servers, effective_spindle_count is effectively the number of independent I/O queues, typically equal to the CPU count. So a 16-vCPU Postgres with SSD storage caps out at roughly 16 × 2 + 16 = 48 actively running queries before context switching and lock contention erode throughput. There is no benefit to a pool larger than this for CPU/IO-bound workloads — additional concurrency just queues inside Postgres instead of inside the pooler, which is strictly worse (queuing inside Postgres holds locks, queuing in PgBouncer doesn't).
The exception is queries that are mostly waiting on something external (remote FDW calls, pg_sleep, long-running explicit lock waits). For those, you can size up — but those workloads usually shouldn't share a pool with your hot OLTP traffic anyway.
Concrete sizing exercise:
- 16-vCPU primary, SSD: ideal pool ≤ 48
- 4 PgBouncer pods (one per AZ-redundant pod across two app clusters): 48 / 4 = 12 per pod
- 3 application databases sharing the cluster: 12 / 3 = 4 per pod per database
That feels uncomfortably small to most engineers used to thinking in terms of "JDBC pool of 30." It is correct. Run it. Watch SHOW POOLS for cl_waiting > 0. If you see sustained queueing, the answer is almost never "more backends" — it is almost always "find the query taking 800 ms and fix it," because at 48 concurrent backends you can sustain ~6,000 simple queries per second, and if you can't, something is slow.
7. Failure modes worth knowing about
PgBouncer + long transactions. A single client holding BEGIN open while idle (waiting on an external API call, for instance) holds a backend out of the pool. Ten such clients on a pool_size = 10 pool = total stall. Mitigations: idle_in_transaction_session_timeout set on Postgres (kills idle-in-transaction backends after N seconds), application discipline around not opening transactions before slow work, and query_wait_timeout so clients fail fast rather than queueing forever.
PgBouncer + Postgres failover. PgBouncer caches its backend connections. After the primary IP changes, existing pooled backends are dead but PgBouncer doesn't know until it tries to use them. Mitigation: RECONNECT from the admin console after promotion, or use server_lifetime short enough that backends are recycled regularly. The cleanest pattern is a sidecar that watches for failover events (from Patroni/etcd, RDS events, etc.) and issues RECONNECT automatically.
Pgpool + ORM transactions. Most ORMs (Hibernate, ActiveRecord, SQLAlchemy unit-of-work) open a transaction at the start of a request and commit at the end. Every read inside that transaction goes to the primary because Pgpool pins on BEGIN. The "read/write splitting" you bought Pgpool for delivers nothing in this configuration. Mitigation: explicit read-only transactions (BEGIN READ ONLY — Pgpool routes these to replicas) or application-level routing.
Pgpool + non-deterministic SELECTs. SELECT now(), SELECT random(), SELECT * FROM logs ORDER BY id DESC LIMIT 1 against a lagging replica all return different results than against the primary. If your app assumed "Pgpool will magically split reads to replicas safely" without auditing for these, you have silent inconsistency bugs. Mitigation: explicit /* NO LOAD BALANCE */ query hints for queries that must hit the primary, and the disable_load_balance_on_write = transaction setting.
Both + auth churn. Rotating database passwords without updating the pooler's stored hash breaks every connection. Use auth_query (PgBouncer) or pool_hba.conf with auth_query (Pgpool 4.0+) so the pooler reads passwords from Postgres rather than maintaining a separate copy.
8. End-to-end deployment pattern (Kubernetes, managed Postgres)
The pattern I'd recommend for a 2026 production deployment on a managed Postgres with reader and writer endpoints:
┌────────────────┐
app pods ────► │ PgBouncer (rw) │ ──► writer endpoint
│ pool_mode=tx │
│ replicas=3 │
└────────────────┘
┌────────────────┐
app pods ────► │ PgBouncer (ro) │ ──► reader endpoint (platform-load-balanced)
│ pool_mode=tx │
│ replicas=3 │
└────────────────┘
Two separate PgBouncer Deployments, two separate Kubernetes Services, two separate connection strings in the app. The application explicitly picks rw or ro per query. No Pgpool, no query parsing, no automatic anything. The trade-off is that the app now has to know which queries are read-only — which is information the app already has and which the pooler can only guess at.
If your app cannot be modified to split reads from writes (legacy monolith, third-party software), that is the case where Pgpool earns its keep. Put Pgpool in front for routing, PgBouncer between Pgpool and Postgres for multiplexing, and accept the operational tax in exchange for not modifying the app.
9. Bottom line
PgBouncer and Pgpool-II both contain the words "connection pooler" in their description and that is approximately where the similarity ends. PgBouncer is a sharp, narrow tool that does one job — multiplexing — extremely well, with minimal moving parts and minimal operational surface. Pgpool-II is a Postgres traffic manager with a built-in pool that, by itself, doesn't actually multiplex.
Pick PgBouncer by default. Add Pgpool only when you have a specific need for proxy-layer read/write splitting that you cannot solve in the application. Never use Pgpool's automatic failover on a platform that already provides failover. And always size your pool from your CPU/IO budget, not from what feels comfortable.
If you remember one configuration value from this entire tutorial, make it pool_mode = transaction and max_prepared_statements = 200 on PgBouncer 1.21+. That single line is the foundation of every modern production Postgres pooling deployment.
Keep reading
Complete Solution: Building a Secure E-commerce Website with Stripe and Laravel
28 min · 140 views
PerformanceDatabase Optimization Techniques for High-Traffic Websites
24 min · 93 views
PerformanceRedis Sentinel vs Cluster for High Availability: A Production Engineer's Guide
10 min · 54 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log In