PostgreSQL Connection Pooling in 2026: PgBouncer, pgpool-II, and Built-in Mechanisms Under Load
Introduction: Why Every PostgreSQL Connection Is Expensive
PostgreSQL handles each connection through a separate operating system process (forked process model). When a connection is established, the database engine forks a process, allocates a stack (~8 MB by default), initializes shared memory structures, and loads system catalogs. Even an "idle" connection consumes 5–10 MB of RAM and occupies a slot in the ProcArray array.
The max_connections parameter limits the total number of simultaneous connections. Increasing it is not a free operation: with max_connections = 1000, PostgreSQL reserves roughly 80 MB of shared memory just for the lock array (max_locks_per_transaction * max_connections * 2). Additionally, the query planner must iterate over all active processes when taking an MVCC snapshot — an O(N) operation per query.
In practice, an application with 50 workers, each maintaining a pool of 10 connections, results in 500 real connections to PostgreSQL. In a microservice architecture with 20 services, that number grows to 10,000, making the database unstable even on standard hardware. This is exactly where PostgreSQL connection pooling comes into play.
Pooling Modes: Session, Transaction, Statement
Connection poolers operate in three modes, and the choice of mode determines compatibility with PostgreSQL features.
Session pooling
A connection from the pool is assigned to a client for the entire duration of its session and is returned only after the client fully disconnects. This is the safest mode: SET, LISTEN/NOTIFY, prepared statements, and advisory locks are all supported. However, there is effectively no multiplexing — the number of clients cannot exceed the pool size.
Transaction pooling
A connection is returned to the pool after a transaction completes (COMMIT / ROLLBACK). This allows 1,000 clients to efficiently share 50 real connections, provided they do not hold transactions open. Limitations: SET SESSION, session-level advisory locks, LISTEN, and server-side prepared statements (by default) are not supported.
Statement pooling
A connection is returned after each individual statement. This is the most aggressive mode — multi-statement transactions are not supported. It is used extremely rarely, primarily in analytical read-only scenarios.
Summary comparison of modes across key parameters:
- Session: multiplexing — no; prepared statements — yes; transactions — yes; LISTEN/NOTIFY — yes.
- Transaction: multiplexing — yes; prepared statements — limited; transactions — yes; LISTEN/NOTIFY — no.
- Statement: multiplexing — maximum; prepared statements — no; transactions — no; LISTEN/NOTIFY — no.
PgBouncer: Installation, Configuration, and Practice
PgBouncer is a lightweight single-threaded pooler written in C using libevent. As of 2026, the current branch is 1.23+, which includes SCRAM-SHA-256 support and an improved TLS stack.
Installation
# Debian/Ubuntu
apt-get install pgbouncer
# Or via Docker
docker run -d \
--name pgbouncer \
-e DATABASE_URL="postgres://user:pass@pg-host:5432/mydb" \
-p 5432:5432 \
edoburu/pgbouncer:latest
Main pgbouncer.ini Configuration
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
; Pool mode — transaction for high loads
pool_mode = transaction
; Maximum connections to PostgreSQL
max_client_conn = 10000
default_pool_size = 50
; Reserve pool for traffic spikes
reserve_pool_size = 10
reserve_pool_timeout = 5
; Timeouts
server_idle_timeout = 600
client_idle_timeout = 0
query_timeout = 0
; Logging
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
stats_period = 60
; Administrative interface
admin_users = pgbouncer_admin
Authentication File userlist.txt
"myuser" "SCRAM-SHA-256$4096:base64salt==:base64storedkey==:base64serverkey=="
"pgbouncer_admin" "adminpassword"
The Prepared Statements Problem in Transaction Mode
With pool_mode = transaction, PostgreSQL-side prepared statements (PREPARE / EXECUTE) do not work because they are tied to a session. The solution is to disable prepared statements at the driver level, or use PgBouncer 1.21+ with the max_prepared_statements parameter, which enables tracking and redirection of prepared statements.
; pgbouncer.ini — enable server-side prepared statements tracking
max_prepared_statements = 200
In Go (pgx), for compatibility with transaction pooling, use QueryExecModeSimpleProtocol or cache queries on the client side via the pgx/v5 extended query cache.
pgpool-II: Load Balancing, Replication, and Justified Complexity
pgpool-II is a multi-functional middleware layer: a pooler, load balancer, replication manager, and query cache. Unlike PgBouncer, it is multi-process and supports the full PostgreSQL protocol.
Key Features of pgpool-II
- Load balancing: automatic distribution of SELECT queries across replicas (standby servers).
- Connection pooling: similar to PgBouncer, but with full protocol mode support.
- Watchdog: HA cluster of multiple pgpool-II nodes with a virtual IP.
- Online recovery: automatic reconnection of nodes after a failure.
- Query cache: in-memory caching of SELECT results (rarely used in production due to cache invalidation complexity).
Minimal pgpool.conf Configuration for Load Balancing
listen_addresses = '*'
port = 5433
# Primary
backend_hostname0 = 'pg-primary'
backend_port0 = 5432
backend_weight0 = 1
backend_data_directory0 = '/var/lib/postgresql/data'
backend_flag0 = 'ALLOW_TO_FAILOVER'
# Replica
backend_hostname1 = 'pg-replica'
backend_port1 = 5432
backend_weight1 = 2
backend_data_directory1 = '/var/lib/postgresql/data'
backend_flag1 = 'ALLOW_TO_FAILOVER'
load_balance_mode = on
master_slave_mode = on
master_slave_sub_mode = 'stream'
num_init_children = 100
max_pool = 4
When pgpool-II Is Justified
pgpool-II is justified when you need automatic balancing of read traffic across replicas without changing application code, middleware-level failover, and watchdog for HA. In all other cases, its complexity is excessive — PgBouncer performs better with lower resource consumption.
Built-in Pooling in Drivers: pgx (Go) and Laravel
pgx Connection Pool (Go)
The pgx/v5 library for Go includes pgxpool — a goroutine-safe application-level connection pool. This is client-side pooling: connections are not multiplexed across goroutines, but are reused within a single process.
package main
import (
"context"
"os"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
config, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
if err != nil {
panic(err)
}
// Pool configuration
config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = 3600 * time.Second
config.MaxConnIdleTime = 600 * time.Second
config.HealthCheckPeriod = 60 * time.Second
// For compatibility with PgBouncer in transaction mode
config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
panic(err)
}
defer pool.Close()
}
Limitation: each Pod in Kubernetes has its own pool — with 10 replicas and MaxConns = 25, PostgreSQL receives 250 real connections. Without an external pooler, this can become critical.
Laravel PostgreSQL Pool
Laravel uses PDO, which does not support persistent connection pooling in the classical sense. For Laravel, it is recommended to route traffic through PgBouncer. The config/database.php configuration remains standard — the application is unaware of the pooler:
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', 'pgbouncer-svc'), // PgBouncer address
'port' => env('DB_PORT', '6432'),
'database' => env('DB_DATABASE', 'mydb'),
'username' => env('DB_USERNAME', 'myuser'),
'password' => env('DB_PASSWORD', ''),
'options' => [
// Disable prepared statements for transaction mode
PDO::ATTR_EMULATE_PREPARES => true,
],
],
Critical note: with pool_mode = transaction in PgBouncer, you must set PDO::ATTR_EMULATE_PREPARES => true, otherwise PDO will use PostgreSQL server-side prepared statements, resulting in a prepared statement does not exist error.
Deploying PgBouncer as a Sidecar in Kubernetes
In Kubernetes, the most reliable pattern is deploying PgBouncer as a sidecar container inside the application Pod. This eliminates a network hop and simplifies lifecycle management.
Kubernetes Manifest: Deployment with PgBouncer Sidecar
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
volumes:
- name: pgbouncer-config
configMap:
name: pgbouncer-config
- name: pgbouncer-secrets
secret:
secretName: pgbouncer-secrets
containers:
- name: app
image: myapp:latest
env:
- name: DATABASE_URL
value: "postgres://myuser@localhost:6432/mydb"
- name: pgbouncer
image: pgbouncer/pgbouncer:1.23.0
ports:
- containerPort: 6432
volumeMounts:
- name: pgbouncer-config
mountPath: /etc/pgbouncer
- name: pgbouncer-secrets
mountPath: /etc/pgbouncer/secrets
readOnly: true
resources:
requests:
cpu: "50m"
memory: "32Mi"
limits:
cpu: "200m"
memory: "128Mi"
livenessProbe:
tcpSocket:
port: 6432
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
exec:
command:
- sh
- -c
- |
psql "postgres://pgbouncer_admin:${ADMIN_PASS}@localhost:6432/pgbouncer" \
-c "SHOW VERSION" > /dev/null 2>&1
initialDelaySeconds: 5
periodSeconds: 10
env:
- name: ADMIN_PASS
valueFrom:
secretKeyRef:
name: pgbouncer-secrets
key: admin_password
ConfigMap for pgbouncer.ini
apiVersion: v1
kind: ConfigMap
metadata:
name: pgbouncer-config
namespace: production
data:
pgbouncer.ini: |
[databases]
mydb = host=pg-primary.postgres.svc.cluster.local port=5432 dbname=mydb
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/secrets/userlist.txt
pool_mode = transaction
max_client_conn = 500
default_pool_size = 20
reserve_pool_size = 5
server_idle_timeout = 300
log_connections = 0
log_disconnections = 0
admin_users = pgbouncer_admin
Secret with userlist.txt
apiVersion: v1
kind: Secret
metadata:
name: pgbouncer-secrets
namespace: production
type: Opaque
stringData:
userlist.txt: |
"myuser" "md5hash_or_scram_verifier"
"pgbouncer_admin" "adminpassword"
admin_password: "adminpassword"
Important: with the sidecar pattern, set listen_addr = 127.0.0.1 — PgBouncer is only accessible from within the Pod, preventing unauthorized external access.
Benchmarks: pgbench Under Load
Test environment: PostgreSQL 16.3, 8 vCPU, 32 GB RAM, NVMe SSD. pgbench TPC-B-like scenario, 100 clients, 60 seconds, scale factor 100.
Scenario 1: No Pooling, Direct Connections
pgbench -h pg-host -p 5432 -U myuser -d mydb \
-c 100 -j 4 -T 60 -P 10
# Result:
# TPS (without connection establishment): 3,241
# Latency average: 30.8 ms
# Connection time: 12.3 ms (avg)
Scenario 2: Via PgBouncer, Transaction Mode, pool_size=50
pgbench -h pgbouncer-host -p 6432 -U myuser -d mydb \
-c 100 -j 4 -T 60 -P 10
# Result:
# TPS (without connection establishment): 8,947
# Latency average: 11.2 ms
# Connection time: 0.4 ms (avg)
Scenario 3: Via pgpool-II, Load Balancing Disabled
pgbench -h pgpool-host -p 5433 -U myuser -d mydb \
-c 100 -j 4 -T 60 -P 10
# Result:
# TPS (without connection establishment): 6,183
# Latency average: 16.2 ms
# Connection time: 1.8 ms (avg)
Benchmark conclusions:
- PgBouncer in transaction mode delivers a 2.76x TPS improvement over direct connections by eliminating connection establishment overhead.
- pgpool-II without load balancing shows a 1.91x improvement — lower than PgBouncer due to its multi-process architecture.
- With load balancing enabled across a read replica and 70% SELECT traffic, pgpool-II achieves a combined TPS of ~14,000, which is unattainable in a single-node scenario.
Common Configuration Mistakes and Their Symptoms
1. Connection Leaks
Symptom: the cl_waiting counter in SHOW POOLS keeps growing. Cause: the application is not returning connections to the pool (unclosed transactions, exceptions without rollback). Diagnostics:
-- In psql connected to the PgBouncer admin database
SHOW CLIENTS;
-- Look for clients with state 'active' and no recent activity
-- Or via pg_stat_activity on the PostgreSQL side
SELECT pid, usename, state, query_start, state_change, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND state_change < NOW() - INTERVAL '5 minutes';
2. "Prepared Statement Does Not Exist" Error
Symptom: ERROR: prepared statement "s1" does not exist. Cause: the driver is using the extended query protocol with server-side prepared statements while pool_mode = transaction. Fix: set PDO::ATTR_EMULATE_PREPARES = true for Laravel, QueryExecModeSimpleProtocol for pgx, or enable max_prepared_statements in PgBouncer ≥ 1.21.
3. Pool Exhaustion
Symptom: ERROR: no more connections allowed (max_client_conn) or clients queuing indefinitely. Cause: insufficient max_client_conn or a default_pool_size that is too small. Fix: increase reserve_pool_size, configure client_login_timeout, and check for long-running transactions blocking connection returns.
4. Incorrect Healthcheck in Kubernetes
Symptom: Pod restarts chaotically. Cause: the readinessProbe checks the TCP port, but PgBouncer is already listening before it is ready to serve requests. Use SHOW VERSION via psql as the healthcheck command (see the manifest example above).
5. SET Commands with Transaction Pooling
Symptom: session settings (SET search_path, SET work_mem) do not persist between requests. Cause: the session is reassigned after each transaction. Fix: use server_reset_query = DISCARD ALL and set parameters via the connection string (options=-c search_path=myschema) or with ALTER ROLE ... SET.
Pool Monitoring: SHOW POOLS, SHOW STATS, and Prometheus
Built-in PgBouncer Diagnostics
-- Connect to the PgBouncer admin database
psql -h localhost -p 6432 -U pgbouncer_admin pgbouncer
-- Pool status
SHOW POOLS;
-- Columns: database, user, cl_active, cl_waiting, sv_active,
-- sv_idle, sv_used, sv_tested, sv_login, maxwait
-- Traffic statistics
SHOW STATS;
-- total_xact_count, total_query_count, total_received,
-- total_sent, total_xact_time, avg_xact_time, avg_query_time
-- Client list
SHOW CLIENTS;
-- Server connection list
SHOW SERVERS;
-- Configuration
SHOW CONFIG;
-- Reload configuration without restart
RELOAD;
Prometheus Integration
Use pgbouncer_exporter from the Prometheus Community. Add it as an additional sidecar container:
- name: pgbouncer-exporter
image: prometheuscommunity/pgbouncer-exporter:v0.9.0
args:
- --pgBouncer.connectionString=postgresql://pgbouncer_admin:$(ADMIN_PASS)@localhost:6432/pgbouncer
ports:
- containerPort: 9127
name: metrics
env:
- name: ADMIN_PASS
valueFrom:
secretKeyRef:
name: pgbouncer-secrets
key: admin_password
Key metrics for alerting:
pgbouncer_pools_cl_waiting— clients in the queue. Alert when value > 10 for 1 minute.pgbouncer_pools_sv_idle— idle server connections. A low value under high load indicates pool exhaustion.pgbouncer_stats_avg_query_time— average query latency.pgbouncer_pools_maxwait— maximum wait time in seconds. Alert when > 1.
Example Prometheus Alert Rule
groups:
- name: pgbouncer
rules:
- alert: PgBouncerPoolExhausted
expr: pgbouncer_pools_cl_waiting > 10
for: 1m
labels:
severity: critical
annotations:
summary: "PgBouncer pool exhausted"
description: "{{ $value }} clients waiting in pool {{ $labels.database }}"
- alert: PgBouncerHighWaitTime
expr: pgbouncer_pools_maxwait > 1
for: 30s
labels:
severity: warning
annotations:
summary: "High wait time in PgBouncer"
Strategy Selection Recommendations for Different Architectures
Monolith or Small Service (Up to 5 Replicas)
Use the driver's built-in pool (pgxpool for Go, Laravel's standard PDO connection pool). An external pooler adds latency and complexity without meaningful benefit when the number of connections is small. Recommended MaxConns: number of workers × 2–4.
Microservices in Kubernetes (10+ Services, 3+ Replicas Each)
Deploy PgBouncer as a sidecar in each Pod with pool_mode = transaction. This reduces the actual number of connections to PostgreSQL from thousands to dozens in aggregate. Set default_pool_size = 10–20 per Pod and configure PostgreSQL's max_connections to 200–500.
High-Traffic Read-Heavy Services (Streaming Replication, Multiple Replicas)
pgpool-II is justified for automatically balancing SELECT queries across replicas without code changes. An alternative is HAProxy or Patroni with a Kubernetes Service configured to split read/write traffic at the network level.
OLAP / Analytical Queries
Use session pooling or no pooling at all — long analytical queries do not benefit from transaction multiplexing and may suffer from pooler overhead when working with CURSOR and COPY.
Laravel Applications in Kubernetes
Laravel with PHP-FPM does not maintain persistent connections between requests — each PHP worker opens a connection on startup and closes it (or holds it depending on configuration). PgBouncer as a sidecar or as a separate Deployment (one per namespace) is a mandatory architectural component when running more than 20 workers per Pod.
General Pool Sizing Principles
- Brandt's rule of thumb:
pool_size = (number of CPU cores * 2) + number of diskson the PostgreSQL side. - Effective PostgreSQL
max_connections=default_pool_size * number of application Pods + superuser_reserved_connections. - Start with conservative values and increase them based on
maxwaitandcl_waitingmetrics.
Connection pooling is not a silver bullet. It solves the overhead of connection establishment, but does not replace query optimization, proper indexing, and sound transaction management. Pool monitoring should be part of the baseline observability of any production PostgreSQL system.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →