Databases

PostgreSQL Connection Pooling in 2026: PgBouncer, pgpool-II, and Built-in Mechanisms Under Load

Ruslan Ismailov Published 18 min read
P

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 disks on 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 maxwait and cl_waiting metrics.

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 →