Skip to content
PostgreSQL

PostgreSQL Connection Pooling in Go: pgxpool Explained

How pgxpool connection pooling actually works in Go: MaxConns, MinConns, connection lifetimes, pool exhaustion, and why raising MaxConns isn't always the fix.

GoBackend.dev11 min read
GoPostgreSQLpgxDatabasePerformance

The problem

A Go service opening a new PostgreSQL connection per request works fine in a demo and falls over in production. Each connection means a TCP handshake, a TLS negotiation if you're using sslmode=require, and PostgreSQL forking a new backend process to serve it — commonly 5-10ms of pure setup cost before your query even runs, plus real memory on the Postgres server for the lifetime of that process. At any meaningful request rate, connection setup becomes the dominant cost, not the query.

pgxpool.Pool exists to amortize that cost: open a bounded number of connections once, hand them out to goroutines that need one, and hand them back when the query is done. The problem this article is actually about is what happens once that pool is under-sized, over-sized, or just misunderstood — because the failure mode isn't a crash, it's requests that quietly queue and then time out.

Why it matters

Pool misconfiguration shows up in two opposite, equally common ways:

  • Too small: requests block waiting for a connection during normal traffic, latency spikes, and pool.Acquire starts returning context deadline errors even though the database itself is healthy and idle.
  • Too large: the pool happily hands out 200 connections, PostgreSQL's own max_connections limit (shared across every replica of your service, plus every other service talking to that database) gets exhausted, and other services start failing to connect — an outage you caused without your own service ever throwing an error.

Both are pool-sizing problems, and neither is fixed by guessing. pgxpool gives you the numbers to size it correctly; most production incidents around it come from never having looked at them.

How pgxpool works

pgxpool.Pool is a client-side connection pool — it lives inside your Go process, not as a separate proxy like PgBouncer sitting in front of PostgreSQL. Every replica of your service that creates a pgxpool.Pool gets its own independent pool with its own MaxConns. If you run 6 replicas with MaxConns: 20, your service can open up to 120 connections to PostgreSQL — a number that has to fit inside Postgres's own max_connections setting alongside every other client.

The pool exposes a handful of settings that matter in production:

cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
	return nil, fmt.Errorf("parse pool config: %w", err)
}
 
cfg.MaxConns = 20                        // hard ceiling on this replica's connections
cfg.MinConns = 2                         // kept open even when idle, avoids cold starts
cfg.MaxConnLifetime = 30 * time.Minute   // recycle connections periodically
cfg.MaxConnIdleTime = 5 * time.Minute    // close idle connections above MinConns
cfg.HealthCheckPeriod = 1 * time.Minute  // background liveness check on idle conns
 
pool, err := pgxpool.NewWithConfig(ctx, cfg)
  • MaxConns is the hard limit on how many connections this single pool instance will ever hold, in use or idle. It is per-process, not per-service — six replicas each get their own budget.
  • MinConns are opened eagerly and kept alive even under no load, so the first requests after a deploy don't pay connection-setup latency.
  • MaxConnLifetime forces connections to be closed and reopened periodically, which matters after a pg_terminate_backend, a failover, or a schema change that a long-lived connection's cached plan might not reflect.
  • MaxConnIdleTime lets the pool shrink back toward MinConns when traffic drops, instead of holding MaxConns connections open forever.

Acquiring and releasing a connection

Most calls go through pool.QueryRow/pool.Exec, which acquire a connection, run the query, and release it automatically:

func (r *OrderRepository) GetByID(ctx context.Context, id string) (Order, error) {
	const query = `SELECT id, status, total_cents FROM orders WHERE id = $1`
 
	var o Order
	err := r.pool.QueryRow(ctx, query, id).Scan(&o.ID, &o.Status, &o.TotalCents)
	return o, err
}

A transaction needs one connection held for its entire duration, so it's acquired explicitly via pool.Begin, which checks out a connection and ties it to the transaction until commit or rollback:

func (r *OrderRepository) CreateWithItems(ctx context.Context, o Order, items []Item) error {
	tx, err := r.pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback(ctx) // no-op if Commit already succeeded
 
	if _, err := tx.Exec(ctx, `INSERT INTO orders (id, status) VALUES ($1, $2)`, o.ID, o.Status); err != nil {
		return fmt.Errorf("insert order: %w", err)
	}
	for _, item := range items {
		if _, err := tx.Exec(ctx, `INSERT INTO order_items (order_id, sku) VALUES ($1, $2)`, o.ID, item.SKU); err != nil {
			return fmt.Errorf("insert item: %w", err)
		}
	}
 
	return tx.Commit(ctx)
}

That connection is unavailable to every other goroutine for as long as the transaction is open — a slow transaction doesn't just run slowly, it holds a scarce resource hostage.

What happens when the pool is exhausted

Rendering diagram…

When every connection is checked out, pool.Acquire (and anything that calls it internally, like QueryRow) doesn't fail immediately — it blocks the calling goroutine, waiting for either a connection to be released or the passed-in context to expire. If your handlers set a per-request timeout (see Graceful Shutdown in Go HTTP Servers for where that fits into request handling), the caller eventually gets back a context.DeadlineExceeded-wrapped error, not a distinct "pool exhausted" error — which means it's easy to misdiagnose as "the database is slow" when the database was never touched at all; the request died waiting in line for a connection.

Under sustained pool exhaustion, this compounds: goroutines pile up waiting on Acquire, each holding whatever resources they acquired before the database call (open HTTP request, allocated buffers), and memory climbs alongside latency. A saturated pool is a leading indicator of a service about to fall over entirely, not just a slow-database symptom.

Why raising MaxConns isn't always the fix

The reflex when requests start timing out on Acquire is to bump MaxConns. Sometimes that's correct. Often it isn't, for two concrete reasons:

PostgreSQL's max_connections is a shared, server-wide ceiling. If max_connections = 100 on your database and you run 6 replicas each with MaxConns: 20, you've already budgeted 120 possible connections against a 100-connection server — before counting migrations, an analytics job, or a second service sharing the same database. Raising one service's MaxConns without checking this can push the server past its limit, and PostgreSQL starts rejecting new connections outright with FATAL: sorry, too many clients already — for every service on that database, not just yours.

Each connection costs the server real memory — commonly 5-10MB per backend process depending on work_mem and other per-connection settings — so max_connections isn't an arbitrary number to raise freely either.

If queries are slow, more connections make it worse, not better. A pool exhausted by queries that each take 800ms because of a missing index (see PostgreSQL Indexes and EXPLAIN ANALYZE) doesn't need more concurrent slow queries — it needs the queries fixed. Raising MaxConns from 20 to 60 in that situation just means PostgreSQL now tries to run 60 slow queries at once instead of 20, competing for the same CPU, disk I/O, and buffer cache, often making every individual query slower.

The actual signal is pool.Stat():

func logPoolStats(pool *pgxpool.Pool, logger *slog.Logger) {
	s := pool.Stat()
	logger.Info("pool stats",
		"total_conns", s.TotalConns(),
		"idle_conns", s.IdleConns(),
		"acquired_conns", s.AcquiredConns(),
		"acquire_count", s.AcquireCount(),
		"acquire_duration", s.AcquireDuration(),
		"empty_acquire_count", s.EmptyAcquireCount(),
	)
}
  • AcquireDuration climbing while TotalConns sits at MaxConns means requests are queueing for connections — a real capacity signal.
  • EmptyAcquireCount (acquisitions that had to wait because no connection was immediately free) rising alongside slow query latency points at slow queries holding connections too long, not too few connections.
  • IdleConns near TotalConns most of the time means the pool is probably oversized for actual load.

Production tuning guidance

Bad: a service with MaxConns: 100, deployed at 6 replicas, against a shared Postgres instance with max_connections = 100 and four other services also connecting. Worst case, 600 possible connections against a 100-connection ceiling — the service works fine in isolation during testing and causes an outage the first time traffic and replica count both climb during a deploy.

Good: size MaxConns per replica as roughly (max_connections * 0.7) / number_of_replicas, leaving headroom for migrations, admin connections, and other services, then verify with pool.Stat() under real load rather than guessing further. For a 100-connection Postgres instance with 6 replicas and no other consumers, that's (100 * 0.7) / 6 ≈ 11 — round to MaxConns: 10 and watch AcquireDuration in production before touching it again.

If Postgres itself needs more headroom, consider PgBouncer in transaction mode in front of it rather than raising max_connections indefinitely — it multiplexes many client connections onto fewer real Postgres backends, which is a different trade-off than tuning pgxpool alone and out of scope here, but worth knowing exists once a single database is serving many services.

Common mistakes

  • Setting MaxConns without checking Postgres's max_connections or how many other replicas/services share it.
  • Treating a pool-exhaustion timeout as "the database is down" instead of checking pool.Stat() first — the database can be completely healthy while your pool is starved.
  • Running a slow query inside a long transaction, holding a connection for the duration of unrelated work (an external API call, a slow computation) between Begin and Commit.
  • Never setting MaxConnLifetime, so connections live for the life of the process and never pick up server-side changes like a failover to a new primary.

Testing

Pool behavior under exhaustion is worth a targeted test rather than an assumption: create a pool with MaxConns: 1, hold its one connection open in a goroutine, and assert that a second Acquire respects the context deadline instead of hanging forever.

//go:build integration
 
func TestPool_AcquireTimesOutWhenExhausted(t *testing.T) {
	cfg, _ := pgxpool.ParseConfig(testDatabaseURL)
	cfg.MaxConns = 1
	pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
	if err != nil {
		t.Fatalf("create pool: %v", err)
	}
	defer pool.Close()
 
	held, err := pool.Acquire(context.Background())
	if err != nil {
		t.Fatalf("acquire first connection: %v", err)
	}
	defer held.Release()
 
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()
 
	_, err = pool.Acquire(ctx)
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("expected context.DeadlineExceeded, got %v", err)
	}
}

This is the same behavior a real request sees under pool exhaustion, reproduced deterministically instead of waiting for it to happen in production.

Summary

pgxpool amortizes the real cost of opening PostgreSQL connections, but its settings only work if they're sized against the database's own max_connections, the number of replicas sharing that budget, and what pool.Stat() actually reports under load — not against a guess. When requests start timing out acquiring a connection, check whether the pool is genuinely too small or whether slow queries are holding connections longer than they should; raising MaxConns only helps in the first case, and can turn a slow-query problem into a database-wide outage in the second.