Every Go developer has hit the same wall: an API that works perfectly under load testing at 50 req/s starts throwing too many connections errors the moment real traffic shows up. The culprit is almost always the same — no connection pooling, or misconfigured pooling that leaves performance on the table. Here's how to do it right.
The problem with naive database connections
The simplest Go API opens a new database connection per request. It works fine until it doesn't:
func getUser(w http.ResponseWriter, r *http.Request) {
// Don't do this — opens a new connection on every single request
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
http.Error(w, "db error", 500)
return
}
defer db.Close()
var user User
err = db.QueryRowContext(r.Context(), "SELECT id, email FROM users WHERE id = $1",
r.PathValue("id")).Scan(&user.ID, &user.Email)
if err != nil {
http.Error(w, "not found", 404)
return
}
json.NewEncoder(w).Encode(user)
}
Each call to sql.Open followed by db.Close creates and tears down a TCP connection to PostgreSQL. At 100 req/s that's 100 TCP handshakes per second. PostgreSQL's default max_connections is 100 — you'll hit the ceiling fast.
Setting up a properly configured connection pool
The fix is to create a single *sql.DB instance at startup and reuse it across all requests. database/sql maintains a pool internally; you just need to configure it correctly.
package main
import (
"context"
"database/sql"
"encoding/json"
"log/slog"
"net/http"
"os"
"time"
_ "github.com/lib/pq"
)
type App struct {
db *sql.DB
}
func NewApp(databaseURL string) (*App, error) {
db, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, err
}
// Core pool settings — tune these for your workload
db.SetMaxOpenConns(25) // max simultaneous connections
db.SetMaxIdleConns(10) // connections kept alive when idle
db.SetConnMaxLifetime(5 * time.Minute) // force rotation to avoid stale conns
db.SetConnMaxIdleTime(1 * time.Minute) // close idle conns after 1 min
// Verify the pool is healthy before accepting traffic
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, err
}
return &App{db: db}, nil
}
func main() {
app, err := NewApp(os.Getenv("DATABASE_URL"))
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", app.getUser)
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
slog.Info("starting server", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil {
slog.Error("server error", "error", err)
}
}
The four Set* calls do the heavy lifting. MaxOpenConns is your safety valve against overwhelming PostgreSQL. MaxIdleConns keeps a pool of warm connections ready for bursts. ConnMaxLifetime prevents you from hitting issues with connections that have gone stale through a load balancer or firewall timeout.
Getting the numbers right
The default values for database/sql are surprising: MaxOpenConns defaults to unlimited (0), which means under spike traffic your API will hammer the database with hundreds of simultaneous connections. Start with these rules of thumb, then measure:
- MaxOpenConns: number of API server instances × expected concurrent requests per instance. For a single server handling 100 concurrent requests, set this between 20–30.
- MaxIdleConns: should be ≤ MaxOpenConns. Setting it higher wastes memory on connections that will never be used.
- ConnMaxLifetime: 5 minutes is safe for most environments. If you're behind an AWS RDS proxy, 3 minutes works better.
You can expose pool stats via a health endpoint to observe what's happening:
func (a *App) healthHandler(w http.ResponseWriter, r *http.Request) {
stats := a.db.Stats()
status := map[string]any{
"open_connections": stats.OpenConnections,
"in_use": stats.InUse,
"idle": stats.Idle,
"wait_count": stats.WaitCount,
"wait_duration_ms": stats.WaitDuration.Milliseconds(),
"max_idle_closed": stats.MaxIdleClosed,
"max_lifetime_closed": stats.MaxLifetimeClosed,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
WaitCount is the most important metric here. If it's non-zero and growing, your pool is undersized — requests are queuing up waiting for a free connection. MaxIdleClosed growing quickly means MaxIdleConns is too low for your traffic pattern.
Query patterns that won't kill your performance
Connection pooling solves the connection problem, but slow queries will still choke your API. A few patterns that matter in production:
Always pass context through to database calls. This lets you cancel queries when the client disconnects, freeing up the connection faster:
func (a *App) getUser(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
// r.Context() propagates cancellation from the HTTP layer to the DB query
var user User
err := a.db.QueryRowContext(r.Context(),
"SELECT id, email, created_at FROM users WHERE id = $1", userID,
).Scan(&user.ID, &user.Email, &user.CreatedAt)
if err == sql.ErrNoRows {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
slog.ErrorContext(r.Context(), "query failed", "error", err, "user_id", userID)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
For write-heavy endpoints, use db.BeginTx with an explicit transaction rather than relying on individual statement autocommit. This holds the connection for the duration of the transaction and gives you rollback semantics.
Observability and security considerations
A few things that often get skipped in tutorials but matter in production:
Connection string security: never construct DATABASE_URL by concatenating user input. Use environment variables, and if you're running on Kubernetes, mount them from secrets — not from a ConfigMap. For a practical checklist on securing API infrastructure, the hardening checklists at AYI NEDJIMI Consultants cover common misconfigurations including database exposure.
Prepared statements: database/sql prepares and caches statements automatically when you use QueryContext with parameterized queries. This gives you implicit protection against SQL injection and slightly better performance on repeated queries.
Timeout hygiene: set a QueryContext timeout tighter than your HTTP write timeout. If your write timeout is 10s and a query runs for 12s, the client already gave up — but you're still holding a connection. Use context.WithTimeout inside the handler to enforce a database-level deadline.
The takeaway
High-performance Go APIs are not primarily about clever algorithms — they're about resource management. A single, properly configured *sql.DB instance with realistic pool bounds will handle 10x more traffic than the naive per-request approach, with lower latency and fewer cascading failures under load.
The settings aren't magic: MaxOpenConns(25), MaxIdleConns(10), ConnMaxLifetime(5 * time.Minute). Start there, add the health endpoint, watch WaitCount under load, and adjust up or down based on what you actually observe.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)