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:

  1. 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; SQLAlchemy prepared_statement_cache_size=0; node-postgres statement_timeout), or use PgBouncer 1.21+ which can transparently rewrite prepared statements to client-side caching.
  2. LISTEN / NOTIFY — needs a stable session. Either keep a dedicated session-mode pool for notifications, or use a different pub/sub (Redis, NATS).
  3. 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.