Connection Pooling Is a Queue: Sizing pgxpool Against PostgreSQL’s Process Model

Every Go service that talks to PostgreSQL sits on top of a pool, and almost every incident review that mentions “connection exhausted” traces back to the same three blind spots: nobody knows how big the pool actually is across all instances, nobody is watching the wait queue, and nobody remembers that the database pays for every connection with a process. Pooling libraries make the configuration trivial — one connection string, a few defaults — which is exactly why the failure mode survives code review. The defaults are sane for a single instance; they are quietly wrong for a fleet of them.

This post works through connection pooling in pgx and its pgxpool package: why PostgreSQL connections are expensive, what the pool actually does when it runs out, which statistics reveal saturation before it pages you, and how to size the thing against a real max_connections budget.

Why the database side is expensive

A MySQL connection is a thread. A PostgreSQL connection is a process — a full backend with its own memory context. That difference drives everything about how you should pool. Each backend allocates per-connection state up front and, on top of that, per-operation buffers like work_mem for sorts and hash tables. A single idle connection costs relatively little; a hundred active connections running analytic sorts can allocate far more than the sum of their baseline footprints.

The server enforces a hard ceiling through max_connections, which defaults to 100, with three of those slots reserved for superuser connections by default. Once the ceiling is hit, new connections fail immediately. There is no queue at the database — queuing is your job, and the pool is where it lives. This is the crucial inversion to internalize: in-process pools do not exist to make connections faster to open, they exist so that excess demand waits in your process instead of failing at the server.

The pool is a semaphore

Under the hood, pgxpool wraps a resource pool that behaves like a counting semaphore. Acquiring a connection takes a token; when MaxConns connections are all busy, the next Acquire call blocks until a connection is released or the caller’s context is canceled. This is the part teams routinely don’t believe until they see it: an exhausted pool does not error out. It silently converts your database bottleneck into queueing delay on request goroutines, which surfaces later as gateway timeouts that look nothing like database errors in the traces.

The defaults from the pgxpool documentation are worth memorizing, because they interact:

  • pool_max_conns — maximum pool size; the greater of 4 or runtime.NumCPU().
  • pool_min_conns — floor the background health check maintains; default 0.
  • pool_min_idle_conns — minimum idle connections kept ready, which trims cold-connection latency from request paths; default 0.
  • pool_max_conn_lifetime — age after which connections are recycled; default 1 hour.
  • pool_max_conn_idle_time — idle duration after which the health check closes a connection; default 30 minutes.
  • pool_health_check_period — cadence of the background health check; default 1 minute.

The simplest setup just encodes the tuning into the connection string:

package main

import (
	"context"
	"log"

	"github.com/jackc/pgx/v5/pgxpool"
)

func main() {
	ctx := context.Background()

	pool, err := pgxpool.New(ctx, "postgres://app:secret@db.internal:5432/appdb"+
		"?sslmode=verify-full"+
		"&pool_max_conns=20"+
		"&pool_min_idle_conns=4"+
		"&pool_max_conn_lifetime=30m"+
		"&pool_max_conn_idle_time=5m")
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()

	var one int
	if err := pool.QueryRow(ctx, "SELECT 1").Scan(&one); err != nil {
		log.Fatal(err)
	}
	log.Println("ok", one)
}

Configuration you can reason about

When the settings need to come from flags or environment rather than a URL, parse the string first and set fields on the returned config. The ParseConfig/NewWithConfig pair keeps the URL parsing and the programmatic tuning separate:

package main

import (
	"context"
	"log"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)

func main() {
	ctx := context.Background()

	config, err := pgxpool.ParseConfig("postgres://app:secret@db.internal:5432/appdb?sslmode=verify-full")
	if err != nil {
		log.Fatal(err)
	}

	config.MaxConns = 20
	config.MinIdleConns = 4
	config.MaxConnLifetime = 30 * time.Minute
	config.MaxConnLifetimeJitter = 5 * time.Minute
	config.HealthCheckPeriod = 15 * time.Second

	pool, err := pgxpool.NewWithConfig(ctx, config)
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()
}

Two of those fields deserve special mention. MaxConnLifetime exists because long-lived connections accumulate state: prepared statement caches grow, failover events leave connections pinned to a dead primary, and load balancers behind the database sometimes route stale sockets. Recycling on a schedule resets all of it. But recycling introduces its own failure mode — if every connection was created around the same moment (say, during a deploy that scaled up instances), they all expire together and the pool stampedes the server with new handshakes. MaxConnLifetimeJitter spreads the expiry randomly across a window so replacement connections trickle in instead of arriving as a herd.

Watch the queue, not just the connections

The pool statistics are where saturation becomes visible before it becomes an incident. Pool.Stat() exposes counters for exactly the semantics described above:

  • EmptyAcquireCount — acquires that had to wait for a connection to be released or constructed because none were free. A few of these are normal; a rising rate is your queue.
  • EmptyAcquireWaitTime — cumulative time spent in those waits, which converts directly into request latency.
  • CanceledAcquireCount — acquires abandoned because the caller’s context expired. These are your timeouts, counted at the source.
  • AcquiredConns, IdleConns, TotalConns — the instantaneous picture of utilization.

A minimal exporter makes the queue a first-class metric:

package main

import (
	"context"
	"log"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)

func reportPoolStats(ctx context.Context, pool *pgxpool.Pool) {
	ticker := time.NewTicker(10 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			stat := pool.Stat()
			log.Printf(
				"total=%d acquired=%d idle=%d empty_acquires=%d wait_ms=%d canceled=%d",
				stat.TotalConns(),
				stat.AcquiredConns(),
				stat.IdleConns(),
				stat.EmptyAcquireCount(),
				stat.EmptyAcquireWaitTime().Milliseconds(),
				stat.CanceledAcquireCount(),
			)
		}
	}
}

The three numbers to alert on are the wait counters. EmptyAcquireWaitTime growing while CPU on the database looks fine is the signature of an undersized pool; CanceledAcquireCount climbing means requests are already timing out inside the queue; a steady EmptyAcquireCount rate with low total wait means the pool is absorbing bursts exactly as designed.

Sizing against a real budget

Pool sizing is multiplication, and the multiplier is the part people forget. If one service instance runs a pool of 20, and the Kubernetes deployment has 12 replicas, that service alone can open 240 connections — against a default max_connections of 100. Every instance-level setting must be divided by the fleet: max_connections minus reservations, divided by the number of instances connecting, minus whatever else shares the database. The safe arithmetic leaves explicit headroom for migrations, admin sessions, and the reserved superuser slots.

On the other side of the equation, bigger is not better. The pool’s job is to bound concurrency to what the server can actually process — for most transactional workloads, that is a function of core count and the CPU cost of your queries, not of request volume. A pool of 200 against a server that can genuinely run 30 queries in parallel just moves the queue from your process to the server’s scheduler, where it competes with everything else and produces worse tail latency.

When many distinct applications — or many short-lived processes, such as serverless functions — need connections to the same database, per-process pools stop adding up and an external pooler like PgBouncer becomes the right tool. PgBouncer multiplexes many client connections onto a small set of server connections. In transaction pooling mode, one server connection is shared by many clients, which breaks session-level state: SET and RESET on the connection, advisory locks, and LISTEN/NOTIFY all assume exclusive ownership of a backend. Newer PgBouncer releases handle protocol-level prepared statements in transaction mode, but any state your application keeps on the connection itself still needs to move into the transaction or into shared storage before the mode switch is safe.

A short checklist

  • Compute the fleet-wide connection budget before setting MaxConns, and leave headroom under max_connections.
  • Set MinIdleConns to cover your steady-state request rate so connection handshakes stay out of request paths.
  • Always set MaxConnLifetimeJitter when MaxConnLifetime is configured.
  • Export EmptyAcquireCount, EmptyAcquireWaitTime, and CanceledAcquireCount — they are the pool’s queue made visible.
  • Reach for PgBouncer when the number of distinct processes, not the concurrency of one process, is the problem.

Pools fail quietly because their worst behavior — waiting — looks like success from the code’s point of view. The counters above turn that wait into a signal, and the arithmetic above turns the signal into a capacity plan. Do both once, and “connection exhausted” stops being a category of incident.

Leave a Reply

Your email address will not be published. Required fields are marked *