The three pool modes
- Session pooling — the app's connection gets a real Postgres backend for the duration of the connection. Returns to the pool on disconnect. Behavior is identical to no pooler; pooling only saves the connect-time TCP/TLS handshake. Safe for everything.
- Transaction pooling — the backend is allocated only for the duration of a transaction (or auto-commit query). Multiple app connections can share one backend over time, multiplexed by transaction. The high-leverage default. Some features break (session-level state: SET, prepared statements, advisory locks, LISTEN/NOTIFY); see caveats below.
- Statement pooling — backend allocated only for a single statement. Even multi-statement transactions can't be used. Extreme; rarely the right answer.
Install on Debian
sudo apt install pgbouncer
Configure
Edit /etc/pgbouncer/pgbouncer.ini:
[databases]
; Database aliases: app connects to "myapp"; PgBouncer routes to real Postgres
myapp = host=127.0.0.1 port=5432 dbname=myapp
; Or a wildcard pointing at any database name
* = host=127.0.0.1 port=5432
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 5
server_idle_timeout = 600
server_lifetime = 3600
; Logging
admin_users = pgbouncer_admin
log_connections = 0 ; turn on temporarily for debugging
log_disconnections = 0
log_pooler_errors = 1
; Stats
stats_period = 60
userlist.txt — SCRAM-hashed passwords for users PgBouncer accepts connections from. Generate with:
echo "\"myapp_user\" \"$(psql -At -h 127.0.0.1 -U postgres -d postgres \
-c "SELECT rolpassword FROM pg_authid WHERE rolname='myapp_user'")\"" \
> /etc/pgbouncer/userlist.txt
chown pgbouncer:pgbouncer /etc/pgbouncer/userlist.txt
chmod 600 /etc/pgbouncer/userlist.txt
Or use auth_query in pgbouncer.ini to look passwords up dynamically (cleaner for many-user setups):
auth_user = pgbouncer_auth
auth_query = SELECT rolname, rolpassword FROM pg_authid WHERE rolname = $1
Then create the pgbouncer_auth role in Postgres with read access to pg_authid.
sudo systemctl restart pgbouncer
sudo systemctl status pgbouncer
Point the app at port 6432 instead of 5432
The only app-side change. Connection strings change from postgres://user:pw@db.host:5432/myapp to postgres://user:pw@db.host:6432/myapp. Everything else is identical.
The transaction-mode caveats
Three features break under transaction pooling because they assume session-level state:
- Server-side prepared statements — the prepared statement is named on the backend session, but transactions rotate which backend the app sees. Either disable prepared statements in the app's driver (libpq
extra_float_digits; SQLAlchemyprepared_statement_cache_size=0; node-postgresstatement_timeout), or use PgBouncer 1.21+ which can transparently rewrite prepared statements to client-side caching. - LISTEN / NOTIFY — needs a stable session. Either keep a dedicated session-mode pool for notifications, or use a different pub/sub (Redis, NATS).
- Advisory locks — same problem. Either session pooling for the affected code path, or restructure to use row-level locks (
SELECT FOR UPDATE).
For most CRUD apps, none of these matter; transaction pooling just works. For background-job systems or migration tools that use advisory locks, point them at a separate PgBouncer database alias configured for session pooling.
The admin console
psql -h 127.0.0.1 -p 6432 -U pgbouncer_admin pgbouncer
# Useful commands
SHOW STATS;
SHOW POOLS;
SHOW SERVERS;
SHOW CLIENTS;
SHOW MEM;
SHOW CONFIG;
# Operational
PAUSE; -- stop accepting new connections
RESUME;
RECONFIG; -- reload pgbouncer.ini
KILL myapp; -- kick all connections to that pool
How much does it help
For a Python web app with 32 workers each holding 5 connections under steady load: 160 Postgres backends without PgBouncer; ~25 with default default_pool_size = 25. Postgres's per-backend RAM (8–16 MB) plus context-switch overhead at high concurrency makes the difference measurable: lower idle memory, fewer file descriptors, more predictable tail latency at peak.
For latency-sensitive code paths, PgBouncer adds ~0.1–0.3 ms per query in the local-network case (it's a TCP proxy with parsing overhead). Negligible vs the value.
Run PgBouncer next to Postgres or next to the app
Two patterns:
- Next to Postgres — one PgBouncer in front of one Postgres. Pool size is the cap on real backend usage; clients still pay network latency to PgBouncer per query. Simpler ops; the default.
- Next to each app instance — PgBouncer on each app server, configured with a small pool to a shared Postgres. Reduces app-to-Postgres connection count to (app_servers × default_pool_size). More moving parts but the right shape at very high scale.
TLS
; Listen with TLS for app connections
server_tls_sslmode = verify-full
server_tls_ca_file = /etc/pgbouncer/ca.crt
client_tls_sslmode = require
client_tls_ca_file = /etc/pgbouncer/ca.crt
client_tls_cert_file = /etc/pgbouncer/server.crt
client_tls_key_file = /etc/pgbouncer/server.key
For PgBouncer-to-Postgres, the canonical setup is both sides on the same host or in the same private VPC; TLS adds latency without much benefit when the network is already trusted.
Alternatives
- Pgcat — Rust pooler with built-in sharding + read/write split + query routing. More features; less battle-tested than PgBouncer.
- Odyssey — Yandex's pooler; supports TLS multiplexing and is used at huge scale; smaller ecosystem.
- App-side pooling (HikariCP, SQLAlchemy's QueuePool, pgx's pool) — reduces app-to-Postgres connections per process but doesn't share across processes. Use both.
For 95% of "we want a Postgres pooler" cases, PgBouncer is still the right answer in 2026 — smallest tool that solves the problem and proven everywhere.