Connection pooling in Go: a hands-on guide for engineers
Database connections are expensive. Opening a fresh TCP connection, negotiating TLS, and authenticating on every query will tank your API's latency and waste your database's resources. Connection pooling in Go solves this by reusing connections across requests, but misconfiguring your pool can create new problems: connection exhaustion, idle resource waste, or stale connections that break under load.
The standard library's database/sql package includes a built-in connection pool that works well out of the box, but tuning it requires understanding three key parameters and monitoring real behavior. We'll walk through each knob you can turn, show you where they bite in production, and give you the metrics to watch.
How the pool works
When you call db.Query() or db.Exec(), the pool either reuses an idle connection or opens a new one (up to a limit). After the query completes, the connection returns to the idle pool. If all connections are busy and you've hit the maximum, your caller blocks waiting for one to free up.
The default settings work for small applications: SetMaxOpenConns defaults to unlimited, and SetMaxIdleConns defaults to 2. But defaults break under real load. Let's tune them properly.
SetMaxOpenConns to protect the database
This parameter sets the hard limit on open connections to your database. If you don't set it, Go will open as many as it wants, and under a traffic spike, that can overwhelm the database server.
Consider a typical scenario: your API runs on three instances, each handling 100 concurrent requests. Without a pool limit, each instance could theoretically open hundreds of connections. Multiply that across three instances, and your Postgres (which defaults to 100 connections) is exhausted before any meaningful load arrives.
How to choose a value: Start with your database's max connections divided by the number of application instances, minus a buffer for other services and maintenance queries.
import (
"database/sql"
"log"
_ "github.com/lib/pq"
)
func main() {
db, err := sql.Open("postgres", "user=app dbname=prod sslmode=require")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Assuming Postgres max_connections=100, 3 app instances
// Reserve 10 for other services: 90 / 3 = 30 per instance
db.SetMaxOpenConns(30)
// Test the connection
if err := db.Ping(); err != nil {
log.Fatal(err)
}
}At a ride-hailing platform, we found that SetMaxOpenConns(25) across six instances gave us headroom during peak hours without starving the database. Your number will depend on query duration and concurrency patterns.
SetMaxIdleConns for reuse
While SetMaxOpenConns limits total connections, SetMaxIdleConns controls how many idle connections the pool keeps warm. Too few, and you reopen connections constantly. Too many, and you waste database resources.
The default of 2 is too low for any production service. If you have 30 max open connections and only 2 idle, most requests will pay the cost of opening a new connection.
// After SetMaxOpenConns
db.SetMaxIdleConns(10)
// Rule of thumb: keep idle conns at 30-50% of max open
// db.SetMaxOpenConns(30)
// db.SetMaxIdleConns(10) // 33% of 30In an e-commerce platform handling checkout flows, we tested this empirically. With idle conns set to 2, p99 query latency was 150ms. Setting it to 8 dropped p99 to 45ms. The tradeoff was minimal extra memory on the database—a worthwhile exchange.
Monitor your actual idle count (more on that later). If it's near zero most of the time, increase it. If it's near max, your traffic pattern doesn't need many warm connections; you can lower it.
SetConnMaxLifetime behind load balancers
Connections don't live forever. They can become stale, especially behind a load balancer or when your database performs maintenance. The database may close a connection on its end, but your application doesn't know until it tries to use it.
SetConnMaxLifetime closes and replaces connections after a duration, forcing a fresh negotiation. Without it, you risk silent connection failures that surface as query timeouts or "connection reset by peer" errors.
import "time"
db.SetMaxOpenConns(30)
db.SetMaxIdleConns(10)
// Close and refresh any connection older than 5 minutes
db.SetConnMaxLifetime(5 * time.Minute)Choose a value based on your environment. In a Kubernetes cluster with frequent pod restarts, 5 minutes is reasonable. Behind a classic load balancer with idle connection timeouts of 60 seconds, set it to 30 seconds—refresh before the LB kills it.
At a travel booking service, we discovered the hard way that without this setting, after database maintenance (which closed idle connections), our pool would hold stale handles. The first request to reuse one would fail. SetConnMaxLifetime(3 * time.Minute) solved it by forcing proactive rotation.
Go 1.15+ also provides SetConnMaxIdleTime, which closes idle connections after a period of inactivity rather than total lifetime. Use this alongside SetConnMaxLifetime to handle both cases:
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(2 * time.Minute) // Go 1.15+Monitor pool stats
Tuning is guesswork without data. The database/sql package exposes pool statistics via DBStats:
import (
"fmt"
"time"
)
// In a monitoring goroutine
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
stats := db.Stats()
fmt.Printf("Open=%d, InUse=%d, Idle=%d, WaitCount=%d, WaitDuration=%v\n",
stats.OpenConnections,
stats.InUse,
stats.Idle,
stats.WaitCount,
stats.WaitDuration,
)
}Export these to your observability stack (Prometheus, CloudWatch, DataDog). Here's what each tells you:
- OpenConnections: Total connections currently open. Should plateau near your
SetMaxOpenConnsduring load. - InUse: Connections actively handling queries. Spikes correlate with traffic.
- Idle: Connections sitting in the pool. If always zero, consider raising
SetMaxIdleConns. - WaitCount: Number of times a goroutine waited for a connection. Increasing rapidly means your pool is too small.
- WaitDuration: Total time spent waiting. High values indicate contention; consider more connections or faster queries.
In practice, instrument this at startup and log periodically:
func logPoolStats(db *sql.DB, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
for range ticker.C {
stats := db.Stats()
log.Printf(
"pool: open=%d in_use=%d idle=%d waits=%d wait_dur=%v",
stats.OpenConnections,
stats.InUse,
stats.Idle,
stats.WaitCount,
stats.WaitDuration,
)
}
}()
}Common mistakes
Setting MaxOpenConns too high: You think more connections = faster queries. It doesn't. Each connection consumes memory and CPU on the database. Set it to what your database can handle, not what you wish it could.
Ignoring MaxIdleConns: Leaving it at 2 means you're constantly opening new connections. This adds latency and CPU overhead. Tune it to keep frequently-used connections warm.
Forgetting about connection timeouts: SetConnMaxLifetime isn't just nice-to-have. In cloud environments with load balancers, NAT gateways, or database proxies that close idle connections, it's essential. Without it, you inherit mysterious connection failures.
Not monitoring:** You can't tune what you don't measure. Log DBStats from day one, even if you think your defaults are fine. They're probably not.
One-size-fits-all settings: A SaaS API with many customers sharing one pool has different needs than a batch job. Your background worker might use SetMaxOpenConns(5) while your API uses 30. Adjust per use case.
Key takeaways
- Set
MaxOpenConnsbased on your database's total connections divided by application instances, minus overhead. - Keep
MaxIdleConnsat 30-50% ofMaxOpenConnsto balance reuse and resource usage. - Use
SetConnMaxLifetimeto rotate stale connections, especially behind load balancers. 3-5 minutes is typical. - Monitor
DBStatscontinuously:OpenConnections,InUse,Idle,WaitCount, andWaitDurationreveal pool health. - Test your settings under realistic load. Lab numbers don't match production behavior.
- Adjust per environment and workload. A batch job, API, and CLI tool each need different tuning.






Comments
Loading comments…