Tuning ConnMaxLifetime and ConnMaxIdleTime in Go

This guide is part of Go database/sql Pool Internals. It covers SetConnMaxLifetime and SetConnMaxIdleTime — the two expiry controls on *sql.DB — and the specific production failure they exist to prevent: driver: bad connection or unexpected EOF on a connection the pool believed was fine.

Both default to zero, and in Go zero means no limit. A *sql.DB created with defaults will keep a connection forever. That is a reasonable default for a database on the same host and an actively dangerous one behind any load balancer, proxy, or managed database endpoint, all of which reap idle TCP connections on their own schedule without telling the client.

What each setter does

db.SetConnMaxLifetime(5 * time.Minute)  // maximum total age of a connection
db.SetConnMaxIdleTime(2 * time.Minute)  // maximum time a connection may sit unused
db.SetMaxOpenConns(25)                  // ceiling on total connections
db.SetMaxIdleConns(25)                  // how many may be kept idle

ConnMaxLifetime is measured from the moment the connection was created, regardless of use. ConnMaxIdleTime, added in Go 1.15, is measured from the last time the connection was returned to the pool. A connection is discarded when either limit is exceeded — they are independent, and the effective lifetime is the shorter of the two conditions to trigger.

Two behaviours in the implementation are worth stating precisely because they surprise people:

  • Expiry is checked on the way out of the pool, not on a timer alone. When a connection is requested, database/sql checks whether the candidate has expired and, if so, closes it and takes the next one. There is also a background connection cleaner goroutine, but it runs on an interval derived from the shorter of the two settings — it is not instantaneous.
  • An expired connection is never handed to a caller. The pool does not validate connections with a ping; expiry is its entire staleness defence. This is the structural difference from a JDBC pool that runs a validation query, and it is why setting these values correctly matters more in Go than the equivalent settings do in Java.
Two independent clocks on one connection A single connection's timeline showing alternating in-use and idle periods. The lifetime clock runs continuously from creation. The idle clock restarts every time the connection is returned to the pool. Lifetime runs once; idle time restarts on every return connection state in use idle in use idle ConnMaxLifetime one continuous clock from creation — never reset ConnMaxIdleTime a fresh clock per idle interval — reset on every return A busy connection can live for hours without ever tripping ConnMaxIdleTime, which is why the lifetime cap is the one that protects you against connection-age limits.
The lifetime clock is absolute; the idle clock restarts on each return. Only the first bounds a continuously busy connection.

Choosing values

The same rule as any pool applies: ConnMaxLifetime must be shorter than the shortest idle or connection-age limit anywhere in the network path.

Deployment Binding external limit ConnMaxLifetime ConnMaxIdleTime
Database on the same host or private subnet none 30m or 0 5m
Behind an AWS Network Load Balancer 350 s idle 4m 90s
Behind PgBouncer (server_idle_timeout 600 s) 600 s 5m 2m
Behind AWS RDS Proxy IdleClientTimeout 1800 s default 20m 5m
Cloud SQL via the connector credential refresh cadence 30m 5m
Autoscaling replicas, frequent topology change DNS TTL 1m5m 30s

The last row is the case people miss. In a database deployment where the writer endpoint can move — an Aurora failover, a Kubernetes service whose backing pods are replaced — long-lived connections pin your pool to yesterday’s topology. A short ConnMaxLifetime forces periodic re-resolution of the endpoint, which is how the pool discovers the new writer without a restart. That mechanism is covered further in AWS Aurora Connection Scaling.

Go does not add jitter to connection expiry. If your pool was filled at startup, every connection reaches ConnMaxLifetime within a few milliseconds of the others, and you get a synchronised reconnect burst. For a pool of 25 that is harmless; for hundreds of pods each holding 25 connections it is a thundering herd against the database. The fix is to jitter it yourself:

func jitter(base time.Duration, pct float64) time.Duration {
    // deterministic per-process spread, seeded from the pod identity
    h := fnv.New32a()
    h.Write([]byte(os.Getenv("HOSTNAME")))
    frac := float64(h.Sum32()%1000)/1000.0*pct - pct/2
    return base + time.Duration(float64(base)*frac)
}

db.SetConnMaxLifetime(jitter(5*time.Minute, 0.20)) // ±10%

A complete configuration

func openDB(dsn string) (*sql.DB, error) {
    db, err := sql.Open("pgx", dsn)
    if err != nil {
        return nil, err
    }

    // Ceiling: this pod's share of the database connection budget.
    db.SetMaxOpenConns(25)

    // Keep the idle set equal to the ceiling so steady traffic never
    // pays reconnect cost inside a request.
    db.SetMaxIdleConns(25)

    // Below the 350 s load balancer idle timeout, with margin.
    db.SetConnMaxLifetime(4 * time.Minute)

    // Release genuinely unused connections back to the database sooner.
    db.SetConnMaxIdleTime(90 * time.Second)

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := db.PingContext(ctx); err != nil {
        return nil, fmt.Errorf("ping: %w", err)
    }
    return db, nil
}

sql.Open does not connect. It validates the driver name and returns immediately; the first connection is established lazily on first use. The explicit PingContext is what turns a misconfigured DSN into a startup failure instead of a runtime one.

Note the relationship between SetMaxIdleConns and SetConnMaxIdleTime. MaxIdleConns caps how many connections may be kept idle; anything beyond it is closed immediately on return. ConnMaxIdleTime bounds how long those retained connections may stay idle. Setting MaxIdleConns below MaxOpenConns means every burst above the idle cap closes connections on return and reopens them on the next burst — a common and expensive misconfiguration that shows up as latency spikes rather than errors.

How the four pool settings interact A stacked view of a pool. MaxOpenConns caps total connections. MaxIdleConns caps how many may be retained when idle; connections above that count are closed on return. The expiry settings then bound how long the retained ones may live. Count limits act first; time limits act on what survives them count limits SetMaxOpenConns — total ceiling, blocks above it SetMaxIdleConns — retained on return, rest closed idle below open causes churn under bursty load time limits SetConnMaxLifetime — absolute age cap SetConnMaxIdleTime — unused-duration cap zero on either means no limit at all A connection is closed the moment any one of the four rules says so. There is no negotiation between them, so the effective behaviour of the pool is always dictated by whichever limit is tightest for the current traffic shape. Set all four explicitly — three of the four defaults are unbounded.
Four independent limits, any of which can close a connection. Three of the four default to unbounded, so silence is not safety.

Verifying with DBStats

database/sql exposes counters that make expiry visible. MaxIdleTimeClosed and MaxLifetimeClosed are the two that confirm these settings are actually firing:

s := db.Stats()
log.Printf("open=%d inuse=%d idle=%d wait=%d waitDur=%s lifetimeClosed=%d idleTimeClosed=%d idleClosed=%d",
    s.OpenConnections, s.InUse, s.Idle,
    s.WaitCount, s.WaitDuration,
    s.MaxLifetimeClosed, s.MaxIdleTimeClosed, s.MaxIdleClosed)

Read the three “closed” counters as a ratio against your request rate:

  • MaxLifetimeClosed should climb steadily and slowly — roughly open_conns / lifetime per second at steady state. A rate far above that means ConnMaxLifetime is too aggressive and you are paying handshake cost constantly.
  • MaxIdleTimeClosed climbing during quiet periods is exactly the intent. Climbing during peak means ConnMaxIdleTime is shorter than the natural gap between requests on a connection.
  • MaxIdleClosed climbing at all is a different signal: it means connections were closed because MaxIdleConns was too low, not because of any timer. Raise MaxIdleConns toward MaxOpenConns.

Export these as gauges alongside WaitCount and WaitDuration — the pattern is described in Prometheus and Grafana Pool Metrics.

Common failure patterns and remediation

Symptom Underlying cause Remediation
driver: bad connection after quiet periods Both settings at their zero default; something upstream reaped the socket Set ConnMaxLifetime below the shortest external idle timer
unexpected EOF only in one environment That environment has a load balancer the others do not Re-derive ConnMaxLifetime per environment, not globally
Latency spikes on bursty traffic, no errors MaxIdleConns below MaxOpenConns, so bursts churn connections Raise MaxIdleConns to match MaxOpenConns
Reconnect storm at a fixed interval across the fleet No jitter; all pods started together Jitter ConnMaxLifetime per process
Pool never notices a failover ConnMaxLifetime of zero pins connections to the old endpoint Set a short lifetime so endpoints are re-resolved
MaxLifetimeClosed growing far faster than expected Lifetime set in seconds where minutes were intended Check the time.Duration unit; 5 is 5 nanoseconds, not 5 seconds
Constant reconnects behind a proxy in transaction mode Lifetime shorter than typical transaction duration Lengthen lifetime; the proxy already recycles server-side connections

Operational boundary

These settings bound connection age. They do not detect a connection that broke during an idle interval shorter than the cap — for that you need the retry that database/sql already performs (it retries a query twice on driver.ErrBadConn before surfacing the error) and a context deadline on every query. They also do nothing for the case where the database itself is refusing connections; that is a sizing and capacity question addressed in Connection Pool Sizing Formulas.

If you find yourself lowering ConnMaxLifetime below about a minute to suppress errors, stop and find the reaper. Something in the path is closing connections aggressively, and a one-minute lifetime is a workaround that costs a handshake per connection per minute forever.

Symptom to setting Three observed symptoms mapped to the setting that governs each: stale connection errors map to ConnMaxLifetime, reconnect latency on bursts maps to MaxIdleConns, and holding too many connections at rest maps to ConnMaxIdleTime. Each symptom points at exactly one setting stale connection errors bad connection, unexpected EOF after quiet periods lower ConnMaxLifetime below the shortest external timer latency spikes on bursts no errors, but handshakes inside the request path raise MaxIdleConns watch MaxIdleClosed to confirm too many idle at rest shared database budget consumed by quiet pods lower ConnMaxIdleTime accepts reconnect cost after quiet Changing more than one at a time makes the DBStats counters ambiguous. Move one setting, redeploy, and read the corresponding counter before touching the next.
Three distinct symptoms, three distinct settings. The DBStats counters confirm which one you actually changed.

Frequently Asked Questions

What happens if I leave both at zero?
Connections live forever. On a database reachable without any intermediary that is fine and avoids reconnect cost. Behind a load balancer, proxy, or managed endpoint it guarantees eventual stale-connection errors.
Which one should I set if I only set one?
ConnMaxLifetime. It is the one that bounds a connection’s total age and therefore the one that protects against every external reaper, including ones that count total age rather than idle time.
Does Go retry automatically when it hands out a dead connection?
database/sql retries a query up to two times when the driver returns ErrBadConn, then falls back to opening a new connection. That covers many cases, but not a connection that dies mid-query, and not a driver that reports the failure as something other than ErrBadConn.
Is ConnMaxIdleTime the same as MaxIdleConns?
No. MaxIdleConns is a count, applied at return time. ConnMaxIdleTime is a duration, applied to connections that were retained. Confusing them is the most common cause of unexplained connection churn in Go services.
Should I jitter the lifetime?
Yes, if you run more than a handful of replicas. Go applies no jitter of its own, so a fleet started by a rolling deploy will expire connections in lockstep.
How do these settings interact with PgBouncer?
Your ConnMaxLifetime governs the client-to-proxy connection only. The proxy’s own server_lifetime and server_idle_timeout govern the proxy-to-database leg independently. Both need setting; see PgBouncer Transaction vs Statement Pooling.
Do these apply to pgx used directly rather than through database/sql?
No. pgxpool has its own equivalents — MaxConnLifetime, MaxConnIdleTime, and MaxConnLifetimeJitter — set on the pgxpool.Config. The reasoning is identical, and pgxpool has the advantage of built-in jitter.