Fix

"too many connections" with Go Database Pools

Fix 'too many connections' in Go by setting MaxOpenConns, MaxIdleConns, and ConnMaxLifetime on your sql.DB instance.

The host stand that manages your tables

Your Go web server handles a few hundred requests without complaint. Then traffic spikes. Suddenly, every database query fails with a driver panic about too many connections. You check your code and see exactly one call to sql.Open. You only asked for one connection. Where did the dozens of extra connections come from.

The database/sql package does not give you a single pipe to your database. It gives you a pool manager. Think of *sql.DB as a restaurant host stand. The host does not cook the food. The host keeps track of empty tables, seats new guests, and sends waiters to the kitchen. When a guest leaves, the table becomes available again. The database driver is the kitchen. It has a hard limit on how many cooks it can handle at once. If the host keeps opening new tables past that limit, the kitchen collapses. *sql.DB manages that table inventory for you, but it ships with default settings that assume a quiet neighborhood restaurant. Production traffic needs explicit limits.

The host stand manages the tables. You manage the limits.

How the pool actually tracks connections

When you call sql.Open, Go does not connect to the database immediately. It validates the driver name and returns a *sql.DB value that is ready to manage connections. The first time you run a query, the pool realizes it needs a real TCP socket. It opens one, runs the driver handshake, and stores the connection in an internal bucket.

Every subsequent query follows the same path. The pool checks for an idle connection. If one exists, it hands it out. If not, it checks whether the total number of open connections is below your configured maximum. If the limit allows it, the pool creates a new socket. If the limit is reached, the calling goroutine blocks until another query finishes and returns its connection to the pool.

Go 1.15 changed the internal tracking to make this behavior predictable. The pool now separates connections into two conceptual groups: idle connections waiting for work, and active connections currently running a query. SetMaxOpenConns caps the sum of both groups. SetMaxIdleConns caps only the waiting group. This separation prevents the pool from hoarding sockets that sit unused while other requests starve.

The pool handles the bookkeeping. You handle the queries.

Minimal configuration

You need three settings to make the pool behave predictably under load. The defaults are intentionally conservative because they cannot guess your database server capacity or your network topology.

package main

import (
	"database/sql"
	"time"
)

// ConfigurePool applies production-safe limits to a database connection pool.
func ConfigurePool(db *sql.DB) {
	// Cap the total number of active connections to prevent overwhelming the database server.
	db.SetMaxOpenConns(25)
	// Keep a baseline of ready-to-use connections so new requests skip the TCP handshake cost.
	db.SetMaxIdleConns(10)
	// Force connections to refresh periodically so they do not get silently dropped by firewalls.
	db.SetConnMaxLifetime(time.Hour)
}

SetMaxOpenConns defines the absolute ceiling. If your PostgreSQL server allows 100 connections and you run three Go instances, set this to 30. Leave room for administrative queries and monitoring tools. SetMaxIdleConns defines the floor. If you set this to zero, the pool closes every connection after a query finishes. The next request pays the full handshake penalty. If you set it too high, the database server wastes memory keeping empty sockets alive. Match it to your typical baseline traffic.

SetConnMaxLifetime runs a background goroutine that marks connections for replacement. TCP keepalives fail. Load balancers drop stale sockets. Databases rotate credentials. If you never expire connections, your application eventually tries to use a socket that the network already closed. The query fails with a driver: bad connection error. Forcing a refresh every hour or two keeps the pool healthy without adding noticeable latency.

Tune the pool to match your database, not your guesswork.

Realistic usage in an HTTP handler

Production code passes the *sql.DB instance around. You create it once during startup and share it across packages. The standard library guarantees that *sql.DB is safe for concurrent use. You do not wrap it in a mutex. You do not create a new instance per request.

package main

import (
	"context"
	"database/sql"
	"net/http"
)

// GetUser fetches a user record by ID and writes the response.
func GetUser(db *sql.DB, w http.ResponseWriter, r *http.Request) {
	// Context carries the request deadline so the pool can cancel waiting queries.
	ctx := r.Context()

	var name string
	// The pool hands out a connection automatically and returns it when this function exits.
	err := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = $1", 1).Scan(&name)
	if err != nil {
		// Return a generic error to avoid leaking database structure details to the client.
		http.Error(w, "database error", http.StatusInternalServerError)
		return
	}

	w.Write([]byte(name))
}

The context.Context parameter is required for all query methods in modern Go. The pool uses it to enforce deadlines. If the client disconnects or the router times out, the context cancels. The pool immediately marks the connection for return and aborts the query on the database side. This prevents a slow client from holding a database socket for minutes while other requests queue up.

Functions that accept a context should always place it as the first parameter. The community convention names it ctx. This keeps signatures consistent and makes it obvious which calls participate in cancellation chains. The if err != nil block looks verbose. The Go community accepts that boilerplate because it forces you to acknowledge the unhappy path instead of swallowing failures.

Pass the pool, not the connection. Let the standard library manage the lifecycle.

Common pitfalls and how the compiler or runtime catches them

Creating a new *sql.DB inside an HTTP handler is the fastest way to crash your database. Each call to sql.Open spawns a fresh pool manager. Under load, your application opens hundreds of independent pools. The database server hits its operating system file descriptor limit or its configured max_connections. The driver returns too many connections or connection refused. The fix is simple. Create the pool once in main() or an initialization function. Export it or pass it through your dependency graph.

Ignoring context cancellation creates a different class of failure. If you call db.Query without a context, or if you pass context.Background() to a request-scoped handler, the pool cannot abort a slow query when the client drops. The connection sits in the active bucket until the database times out. Under sustained load, every connection becomes active. New requests block indefinitely. The pool eventually returns driver: bad connection when the database forcibly closes the stale socket. Always thread the request context through to the database layer.

Forgetting to scan or close a *sql.Rows object leaks connections. The pool tracks open rows. If you call db.Query and never iterate the results or call rows.Close(), the underlying connection remains marked as in use. The pool cannot reuse it. Your application slowly consumes MaxOpenConns until it starves itself. The compiler cannot catch this. You must write the loop or call defer rows.Close() immediately after the query.

The compiler will reject you if you pass the wrong type to a query placeholder. You get cannot use x (type string) as int in argument to QueryRowContext. This is a compile-time safety net. Runtime errors like sql: no rows in result set are not bugs. They are expected outcomes when a SELECT returns empty. Handle them explicitly instead of treating them as failures.

A leaked connection is a silent tax on your entire application.

When to adjust pool settings

Use SetMaxOpenConns when you know your database server hard limit and want to protect it from being overwhelmed by concurrent requests. Use SetMaxIdleConns when you want to balance connection reuse against memory usage on the database side. Use SetConnMaxLifetime when your network infrastructure drops idle TCP connections or when you need to rotate credentials periodically. Use a single shared *sql.DB instance when your application spans multiple packages or HTTP handlers. Use context.WithTimeout when you need to guarantee that a slow query does not hold a connection indefinitely. Use db.Stats() when you need to observe pool behavior in production and adjust limits based on actual wait times and in-use counts.

Where to go next