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.
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.Acquirestarts 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_connectionslimit (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)MaxConnsis 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.MinConnsare opened eagerly and kept alive even under no load, so the first requests after a deploy don't pay connection-setup latency.MaxConnLifetimeforces connections to be closed and reopened periodically, which matters after apg_terminate_backend, a failover, or a schema change that a long-lived connection's cached plan might not reflect.MaxConnIdleTimelets the pool shrink back towardMinConnswhen traffic drops, instead of holdingMaxConnsconnections 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(),
)
}AcquireDurationclimbing whileTotalConnssits atMaxConnsmeans 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.IdleConnsnearTotalConnsmost 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
MaxConnswithout checking Postgres'smax_connectionsor 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
BeginandCommit. - 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.
funcRelated()[]Article
Load Testing a Go API: Finding the Real Bottleneck
Load testing a Go API with k6: throughput, p50/p95/p99 latency, and a systematic way to find whether the bottleneck is the app, the database, or the network.
Scaling PostgreSQL for Go Applications: Read Replicas, Connection Pools and Query Performance
A practical order of operations for scaling PostgreSQL under a growing Go service: query optimization first, then pooling, then read replicas — and why the order matters.
Database Transactions in Go: Isolation Levels, Locks and Real-World Failures
PostgreSQL transaction isolation levels in Go, explained through a real race condition: lost updates, SELECT FOR UPDATE, and why app-level locking isn't enough.