Use PgBouncer when the problem is “too many connections”: it is a single small process that multiplexes thousands of client connections onto a handful of server ones. Use Pgpool-II when you need a routing layer in front of several PostgreSQL servers — read load balancing, automatic failover — and accept operating something closer to a second database server than a proxy. For plain pooling on one server, PgBouncer.
I ran both on this machine against the same scratch PostgreSQL 17.11 cluster (port 55611, throwaway database and user hwcmp_20817, deleted after). Versions, from the binaries themselves:
$ pgbouncer --version
PgBouncer 1.25.2
libevent 2.1.13-stable
adns: evdns2
tls: OpenSSL 3.6.3 9 Jun 2026
$ pgpool --version
pgpool-II version 4.7.2 (tasukiboshi)Table of Contents
PgBouncer: five clients, two server connections
The whole working config is this:
[databases]
hwcmp_20817 = host=127.0.0.1 port=55611 dbname=hwcmp_20817
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = trust
admin_users = hwcmp_20817
pool_mode = transaction
default_pool_size = 2Connecting through port 6432 lands on the real server — inet_server_port() reports the backend’s port, not PgBouncer’s:
$ psql -h 127.0.0.1 -p 6432 -U hwcmp_20817 hwcmp_20817 -c "SELECT current_database(), inet_server_port();"
current_database | inet_server_port
------------------+------------------
hwcmp_20817 | 55611Then the point of the tool: I opened five concurrent clients, each running SELECT pg_sleep(4);, with a pool of two. PgBouncer’s admin console (psql -p 6432 ... pgbouncer) showed two clients being served, three queued, and only two connections open to PostgreSQL:
pgbouncer=# SHOW POOLS;
database | user | cl_active | cl_waiting | sv_active | maxwait | pool_mode
-------------+-------------+-----------+------------+-----------+---------+-------------
hwcmp_20817 | hwcmp_20817 | 2 | 3 | 2 | 1 | transaction
-- pg_stat_activity on the backend itself, same moment:
backend_conns
---------------
2Five clients, two backends. That ratio is the entire value proposition, and it works because pool_mode = transaction returns the server connection to the pool at every commit.
The trap in transaction pooling, executed
Transaction pooling means consecutive statements from one client can run on different backends — and one backend serves many clients. With default_pool_size = 1, client A set a session variable, disconnected, and a brand new client B inherited it:
-- client A, through PgBouncer:
SET statement_timeout = '5s';
-- client B, separate psql invocation, through PgBouncer:
SHOW statement_timeout; --> 5s
-- fresh connection straight to the backend on 55611:
SHOW statement_timeout; --> 0That leaked 5s is real output, not a thought experiment. The PgBouncer feature list is explicit that transaction pooling never supports SET/RESET, PREPARE/DEALLOCATE, LISTEN, or session-level advisory locks — the docs say it “breaks client expectations of the server by design”. If your app needs those, you run PgBouncer in session mode and give up most of the multiplexing.
Pgpool-II: heavier before the first query
Pgpool-II announced its scope before accepting a connection. On a stock macOS it refused to start, asking for 134 MB of System V shared memory against the default 4 MB limit — most of it two 64 MB query-cache segments allocated even with the cache disabled:
FATAL: could not create shared memory for request size: 134580304
DETAIL: shared memory creation failed with error "Invalid argument"Shrinking memqcache_total_size got it running. With num_init_children = 4 it was 8 OS processes to PgBouncer’s 1 (the default is 32 preforked children). Each child serves exactly one client at a time: this is session pooling, so when I repeated the SET statement_timeout test through Pgpool-II, client B saw the default 0 — no leak. The other side of that coin, also measured: with all 4 children occupied by sleeping clients, a fifth connection simply hung for 3 seconds until a child freed up. That matches the docs: num_init_children “is also the concurrent connections limit”, and extra clients “are blocked (not rejected with an error)”.
What you get for the weight is a cluster manager. Even with one backend it tracks node state:
$ psql -h 127.0.0.1 -p 55633 -U hwcmp_20817 hwcmp_20817 -x -c "SHOW POOL_NODES;"
node_id | 0
hostname | 127.0.0.1
port | 55611
status | up
role | primary
lb_weight | 1.000000
load_balance_node | true
replication_delay | 0What was executed here and what was not
Everything above — both poolers, the multiplexing, the state leak, the blocking — ran on this machine. What did not run is Pgpool-II’s multi-server act, because I configured a single backend: the official docs are the source for it distributing read queries over multiple servers and automatically removing a broken server from the pool, with a watchdog process to keep Pgpool-II itself from being the single point of failure. Those features are the actual reason to choose it. If you never plan to run more than one PostgreSQL server, you are paying Pgpool-II’s operational cost for a pooler that caps clients at num_init_children — PgBouncer does that one job with one process and a nine-line config.
Check this yourself. Every command and every block of output on this page is reproduced by /verify/pgbouncer-vs-pgpool-ii.sh. Download it and run it: it builds its own scratch cluster, prints one line per claim, cleans up after itself, and exits non-zero if any claim here turns out to be wrong. If it disagrees with this page, the page is wrong.