Connection Pooling — Postgres's Most Neglected Side#
"Postgres connection = process. 10 MB RAM. 500 connections = 5 GB. 40% of teams that say 'the DB got slow' are actually hitting pool exhaustion. A connection leak is sneakier than a slow query."
This guide covers the practical details of Postgres connection pooling — PgBouncer in particular — the modern alternatives pgcat and app-side pooling, and the answer to "what's the right pool size?"
🎯 Why Pool?#
Postgres connection economics#
1 connection = 1 OS process
~10 MB RAM
~1 ms forking time
500 connections = 5 GB RAM (even idle)
= OS scheduler stress
= lock contention
The app-side problem#
[App pod 1] ── 50 conn ──┐
[App pod 2] ── 50 conn ──┤
[App pod 3] ── 50 conn ──┤ → 500 conn → DB OOM
[...] │
[App pod 10] ── 50 conn ──┘
The pooler solution#
[App pod 1] ── 50 conn ──┐
[App pod 2] ── 50 conn ──┤ → [PgBouncer] ── 25 conn ──▶ Postgres
[App pod 3] ── 50 conn ──┤
[...] │ (thousands of clients → dozens of DB conns)
[App pod 10] ─ 50 conn ──┘
🔑 A pool multiplies 1 worker into dozens of DB conns for thousands of client conns.
🛠️ PgBouncer — The De Facto Standard#
Choosing a pool mode#
| Mode | Description | Limit |
|---|---|---|
| Session | Default; client-conn = DB-conn lifetime | Pool runs dry, idle timeout is hard to tune |
| Transaction | Conn freed at end of transaction | Best fit for most apps |
| Statement | Freed after every statement | Multi-statement TX forbidden |
Transaction mode (recommended)#
# pgbouncer.ini
[databases]
app = host=<DB_HOST> port=5432 dbname=app
[pgbouncer]
listen_addr = *
listen_port = 6432
# Auth
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
# Mode
pool_mode = transaction
# Pool sizing
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 5
max_client_conn = 1000 # total max from clients
max_db_connections = 50 # total max toward the DB
# Timeout
server_idle_timeout = 600 # drop idle conn after 10 min
server_lifetime = 3600 # recycle after 1 hour
query_wait_timeout = 120
# TLS
server_tls_sslmode = require
client_tls_sslmode = require
client_tls_cert_file = /etc/pgbouncer/server.crt
client_tls_key_file = /etc/pgbouncer/server.key
# Logging
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
log_stats = 1
stats_period = 60
# Admin
admin_users = pgbouncer_admin
stats_users = pgbouncer_stats
userlist.txt#
🔑 MD5 is deprecated — always use SCRAM-SHA-256.
Prepared statements in transaction mode#
Problem: in transaction mode, a prepared statement doesn't carry over to the next conn.
Solution: - Postgres 14+ and PgBouncer 1.21+ → protocol-level prepared statement support via max_prepared_statements > 0 - Or on the app side: prepareThreshold=0 (JDBC), prepare=False (psycopg)
📐 Pool Size Calculation — The Most Common Mistake#
The classic formula (Brett Wooldridge — HikariCP)#
-core_count: number of CPU cores on the DB server - effective_spindle_count: SSD = 0, HDD = number of disks Example: 16-core SSD DB → 16 × 2 + 0 = 32 connections.
Practical reality#
- Not 32 total across all apps — spread it out
- Per app: 25 (multiplexing already happens behind PgBouncer)
- DB side:
max_connections = 100+ buffer (admin, monitoring)
Reactive pool sizing#
# Scale up if pool usage is 80%+
pgbouncer_pool_used_clients / pgbouncer_pool_max_clients > 0.8
# Server side waiting clients
pgbouncer_pool_waiting_clients > 0
⚖️ PgBouncer vs pgcat vs Odyssey#
| Feature | PgBouncer | pgcat | Odyssey |
|---|---|---|---|
| Language | C | Rust | C |
| Age | 2007, mature | 2022, modern | Yandex, 2019 |
| TLS | ✅ | ✅ | ✅ |
| Transaction mode | ✅ | ✅ | ✅ |
| Sharding | ❌ | ✅ Native | ✅ |
| Read replica routing | ❌ | ✅ | ✅ |
| Prepared statements (TX mode) | ⚠️ Postgres 14+ | ✅ Native | ✅ |
| Performance | Good | Excellent (multi-thread) | Excellent |
| Community | Wide | Growing | Niche |
| 2026 recommendation | ✅ Stable | ✅ Modern, sharding | ✅ Yandex/Postgres pro |
🔑 Stable + widespread → PgBouncer. Sharding/multi-replica routing → pgcat.
🌐 pgcat — A Modern Alternative#
Why?#
- Multi-threaded (PgBouncer is single-threaded)
- Native sharding (key-based)
- Read/write split (primary vs replica)
- Native prepared-statement passthrough
Config#
# pgcat.toml
[general]
host = "0.0.0.0"
port = 6432
admin_username = "admin"
admin_password = "<PWD>"
[pools.app]
pool_mode = "transaction"
default_role = "any"
query_parser_enabled = true
[pools.app.users.0]
username = "app"
password = "<PWD>"
pool_size = 25
[pools.app.shards.0]
servers = [
["primary-host", 5432, "primary"],
["replica-1-host", 5432, "replica"],
["replica-2-host", 5432, "replica"],
]
database = "app"
📦 PgBouncer Deployment on K8s#
apiVersion: apps/v1
kind: Deployment
metadata:
name: pgbouncer
namespace: postgres
spec:
replicas: 3
selector:
matchLabels: {app: pgbouncer}
template:
metadata:
labels: {app: pgbouncer}
spec:
containers:
- name: pgbouncer
image: edoburu/pgbouncer:<VERSION>
ports:
- containerPort: 6432
env:
- name: DB_HOST
value: postgres-prod-rw.postgres.svc
- name: DB_USER
valueFrom: {secretKeyRef: {name: pgbouncer-creds, key: user}}
- name: DB_PASSWORD
valueFrom: {secretKeyRef: {name: pgbouncer-creds, key: password}}
- name: POOL_MODE
value: transaction
- name: DEFAULT_POOL_SIZE
value: "25"
- name: MAX_CLIENT_CONN
value: "1000"
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: 500m, memory: 256Mi}
livenessProbe:
tcpSocket: {port: 6432}
readinessProbe:
tcpSocket: {port: 6432}
---
apiVersion: v1
kind: Service
metadata:
name: pgbouncer
namespace: postgres
spec:
selector: {app: pgbouncer}
ports:
- port: 5432
targetPort: 6432
App config:
🔑 The app talks to PgBouncer (5432). PgBouncer talks to the DB (5432). 3 replicas for HA.
📊 Monitoring + Alerting#
PgBouncer admin console#
psql -h pgbouncer-host -p 6432 -U pgbouncer_admin pgbouncer
pgbouncer=# SHOW POOLS;
# database | user | cl_active | cl_waiting | sv_active | sv_idle | sv_used | maxwait | pool_mode
# app | app | 50 | 0 | 25 | 0 | 0 | 0 | transaction
pgbouncer=# SHOW STATS;
# total_xact_count | total_query_count | ...
pgbouncer=# SHOW CLIENTS;
pgbouncer=# SHOW SERVERS;
Prometheus exporter#
# prometheus-pgbouncer-exporter
- job_name: pgbouncer
static_configs:
- targets: [pgbouncer-exporter:9127]
Key metrics#
# Pool usage ratio
pgbouncer_pool_used_clients / pgbouncer_pool_max_clients
# Waiting clients (pool exhausted)
pgbouncer_pool_waiting_clients
# Query wait time
pgbouncer_pool_max_wait_seconds
# DB conn count (how many are open on the DB side)
pgbouncer_databases_connections
# Server idle (conns sitting unused)
pgbouncer_pool_used_servers - pgbouncer_pool_active_servers
Alert#
groups:
- name: pgbouncer
rules:
- alert: PgBouncerHighPoolUsage
expr: pgbouncer_pool_used_clients / pgbouncer_pool_max_clients > 0.8
for: 5m
- alert: PgBouncerClientsWaiting
expr: pgbouncer_pool_waiting_clients > 5
for: 2m
- alert: PgBouncerHighWait
expr: pgbouncer_pool_max_wait_seconds > 1
for: 1m
🚦 App-Side Pooling#
Sitting behind PgBouncer, do you still need app-side pooling too?
| Scenario | Preference |
|---|---|
| Stateless API (HTTP) | Small app pool (5-10), PgBouncer does the multiplexing |
| Stateful (websocket, long-lived conn) | App pool falls short; PgBouncer transaction mode is critical |
| Serverless (Lambda) | Every invocation opens a new conn — RDS Proxy / PgBouncer mandatory |
App pool settings (e.g. HikariCP, JDBC)#
# spring.datasource.hikari.*
maximum-pool-size=10
minimum-idle=2
connection-timeout=30000
idle-timeout=600000
max-lifetime=1800000
# For PgBouncer:
data-source-properties.prepareThreshold=0 # for transaction mode
🚫 Anti-Pattern Table#
| Anti-pattern | Why it's bad | Correct |
|---|---|---|
| Apps connect straight to the DB | Excess conn count | Put PgBouncer in front |
max_connections=500 without PgBouncer | RAM exhaustion | PgBouncer + lower max_connections |
| Trusting the session-mode default | Pool exhaustion is common | Transaction mode |
| Transaction mode + prepared statements (old) | Statement gets lost | Postgres 14+ + protocol-level |
pool_mode = statement | Multi-statement tx forbidden | Prefer transaction mode |
| Single PgBouncer instance | SPOF | 3+ replicas |
PgBouncer auth md5 | Deprecated | scram-sha-256 |
| App pool size = DB max conn | PgBouncer's benefit is lost | App pool 5-10, PgBouncer multiplexing |
| Server idle timeout set to infinite | Idle conns clog the DB | 600s |
| No monitoring | "DB got slow" → cause unclear | exporter + alert |
idle in transaction connection leak | Pool runs dry | Tx commit/rollback hygiene in the app |
| No TLS | A sniffer reads the password | server_tls + client_tls |
📋 Connection Pooling Checklist#
[ ] PgBouncer (or pgcat) installed, 3+ replicas
[ ] Transaction mode
[ ] SCRAM-SHA-256 auth
[ ] TLS server + client
[ ] Pool sizing: 25-50 default per pool
[ ] max_client_conn high (1000+)
[ ] max_db_connections low (below DB max_connections)
[ ] Small app pool size (5-10)
[ ] Server idle timeout 600s
[ ] Server lifetime 3600s
[ ] Prometheus exporter + alert
[ ] Pool usage dashboard
[ ] App-side: tx commit/rollback hygiene
[ ] Postgres 14+ + max_prepared_statements (for TX mode)
[ ] PgBouncer pods on different nodes (anti-affinity)
[ ] LB via HAProxy / Service
[ ] Quarterly: pool sizing review
📚 References#
- PgBouncer Docs — pgbouncer.org
- pgcat — github.com/postgresml/pgcat
- Odyssey — github.com/yandex/odyssey
- HikariCP Pool Sizing — github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing
- AWS RDS Proxy — aws.amazon.com/rds/proxy/
Postgres-Production-Guide.mdHA-Patroni-Stolon.mdMonitoring-Postgres.md11-SRE/Runbook-Template.md— pool exhaustion runbook
"A connection pool is a design decision, not something you just run. A poolless system might say it's 'running fine' — until a traffic spike hits and the team lands in a pool exhaustion incident, paying the bill for a design decision made 6 months earlier."