Imagine this.

Your application has been running perfectly for weeks. APIs are responding quickly, database queries are working and everything looks healthy.

Then, suddenly:

  • API requests start taking several seconds.
  • Some requests time out, while others never seem to complete.
  • New database operations stop working.
  • Restarting the application temporarily fixes everything.
  • A few minutes or hours later, the same problem returns.

You check the database. It's running.

You check the application. No obvious crash.

You check the logs and find something like:

Error: context deadline exceeded Error: connection pool exhausted Error: timeout acquiring connection 

One possible reason?

Your application has run out of available database connections.

This is where database connection pooling becomes extremely important.

Connection pooling is one of those concepts that developers often configure once and forget about. But an incorrectly configured pool, or connections that are never released, can gradually bring an otherwise healthy application to a halt.

In this article, we'll go beyond what connection pooling is. We'll understand how it works, how connections get stuck, how to reproduce pool exhaustion, how to debug it in a real application and which practices help prevent these problems in production.

We'll use Go's database/sql package for practical examples, but the concepts apply to most backend technologies.

1. First, why do we need a database connection pool?

Let's start with a basic question.

What happens when an API needs to execute a database query?

At a high level, the application needs a connection to the database, sends a query, receives the result and then releases the connection for reuse.

Opening a brand-new database connection for every request is expensive.

Establishing a connection can involve:

  • A network connection between the application and database.
  • Authentication.
  • Database session initialization.
  • TLS negotiation, if configured.
  • Other database and driver-specific setup.

Now imagine an application receiving 500 requests simultaneously.

If every request independently opens a new database connection, we could end up creating hundreds of connections unnecessarily.

That introduces overhead and can overwhelm the database server.

Instead, we use a connection pool.

A connection pool maintains a collection of reusable database connections. When an application needs a connection, it borrows one from the pool. Once the operation is finished, the connection is returned so another operation can use it.

Think of it like a shared collection of resources:

  1. A request needs a database connection.
  2. The pool provides an available connection.
  3. The request executes its database operation.
  4. The connection is released back to the pool.
  5. Another request can reuse it.

The important part is that the application doesn't need to establish a new physical connection for every query.

Application to connection pool to database conceptual diagram

One important distinction: A connection pool is not the same as a database connection.

The pool manages connections. Your application borrows and returns them as database operations require.

2. How connection pooling works in Go

Go makes connection pooling particularly interesting because database/sql manages the pool for us.

Consider this example:

db, err := sql.Open("postgres", connectionString) if err != nil { log.Fatal(err) } defer db.Close() 

A common misconception is that sql.Open() immediately establishes a database connection.

It doesn't necessarily do that.

sql.Open() creates a *sql.DB handle, which manages a pool of connections. Actual connections are typically established when they're needed.

If you want to verify that the database is reachable during application startup, use PingContext():

ctx, cancel := context.WithTimeout( context.Background(), 5*time.Second, ) defer cancel() if err := db.PingContext(ctx); err != nil { log.Fatalf("database connection failed: %v", err) } 

Also, notice something important:

*sql.DB is designed to be long-lived and shared across goroutines.

You generally create one database handle during application startup and reuse it throughout the application's lifetime.

You should not create a new sql.DB inside every API handler.

For example, avoid this:

func GetUsers(w http.ResponseWriter, r *http.Request) { db, err := sql.Open("postgres", connectionString) if err != nil { http.Error(w, "Database error", 500) return } defer db.Close() // Execute query... } 

Why?

Because every handler creates its own pool. Instead of sharing a controlled number of connections, you end up with multiple independent pools.

This can cause:

  • Unnecessary connection creation.
  • Too many connections on the database server.
  • More resource consumption.
  • Difficult-to-manage connection limits.

A better approach is to initialize the pool once and inject the shared *sql.DB into your repository or service layer.

type UserRepository struct { db *sql.DB } func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{ db: db, } } 

Now every repository operation uses the same pool.

3. Understanding the connection pool configuration

Go provides several methods to control the pool.

These are worth understanding properly because setting them without understanding their impact can create production issues.

SetMaxOpenConns

db.SetMaxOpenConns(25) 

This defines the maximum number of open connections that the pool can have at a time.

For example, if the maximum is 25:

  • Up to 25 connections can be open.
  • If all 25 are busy, additional operations wait.
  • When a connection becomes available, a waiting operation can use it.

This is one of the most important settings because it controls how many concurrent database operations can hold connections from that pool.

SetMaxIdleConns

db.SetMaxIdleConns(10) 

This defines the maximum number of connections that can remain idle in the pool.

Why keep idle connections?

Because creating a new connection takes time. Keeping a reasonable number of connections available can reduce the overhead of establishing new ones.

But don't confuse idle connections with unused resources that are automatically closed immediately. Idle connections can remain open until they are reused or removed by pool lifecycle rules.

SetConnMaxLifetime

db.SetConnMaxLifetime(30 * time.Minute) 

This sets the maximum amount of time a connection may be reused.

Once a connection reaches its lifetime, it becomes eligible for removal and replacement.

Why is this useful?

  • Database infrastructure may rotate connections.
  • Network devices may terminate long-lived connections.
  • Cloud database environments may have connection lifecycle constraints.
  • Long-lived sessions can accumulate server-side state.

This does not mean an active query is forcibly interrupted exactly when the lifetime expires. The pool applies connection lifetime rules as connections are returned and reused.

SetConnMaxIdleTime

db.SetConnMaxIdleTime(5 * time.Minute) 

This controls how long an idle connection can remain unused before it becomes eligible for closure.

For example, imagine your application receives heavy traffic during the day but very little traffic at night.

You might not need to keep every previously opened connection around during idle periods.

This setting helps the pool clean up connections that have been sitting unused.

Putting everything together

A basic configuration might look like this:

func NewDatabase(dsn string) (*sql.DB, error) { db, err := sql.Open("postgres", dsn) if err != nil { return nil, err } db.SetMaxOpenConns(25) db.SetMaxIdleConns(10) db.SetConnMaxLifetime(30 * time.Minute) db.SetConnMaxIdleTime(5 * time.Minute) ctx, cancel := context.WithTimeout( context.Background(), 5*time.Second, ) defer cancel() if err := db.PingContext(ctx); err != nil { db.Close() return nil, err } return db, nil } 

These values are examples, not universal production defaults. Your actual configuration should depend on database capacity, application concurrency, query behavior, number of instances and workload.

We'll come back to sizing later.

4. The real problem: How does an application run out of connections?

Now let's get to the interesting part.

Suppose your application has the following configuration:

db.SetMaxOpenConns(5) 

That means the pool can have at most five open connections.

Imagine five API requests are executing database operations simultaneously.

All five connections are occupied.

Now a sixth request arrives.

What happens?

It waits until one of the existing operations releases a connection.

This is expected behavior.

But what if those five connections are never released?

The sixth request keeps waiting.

The seventh request keeps waiting.

Eventually, requests start timing out and your application appears to be stuck.

This is called connection pool exhaustion.

The database itself might still be healthy. The problem is that your application cannot obtain a connection from its own pool.

Let's look at the most common reasons.

5. Common causes of connection leaks and pool exhaustion

Problem 1: Forgetting to close query rows

This is one of the easiest mistakes to make.

Consider:

func GetUsers(db *sql.DB) error { rows, err := db.Query("SELECT id, name FROM users") if err != nil { return err } for rows.Next() { var id int var name string if err := rows.Scan(&id, &name); err != nil { return err } fmt.Println(id, name) } return nil } 

Looks perfectly fine, right?

But there's an issue.

We never close rows.

A Rows object can hold a database connection while the result set is being consumed. If it isn't closed, the connection may remain unavailable to other operations.

A safer implementation:

func GetUsers(db *sql.DB) error { rows, err := db.Query("SELECT id, name FROM users") if err != nil { return err } defer rows.Close() for rows.Next() { var id int var name string if err := rows.Scan(&id, &name); err != nil { return err } fmt.Println(id, name) } return rows.Err() } 

Three important things are happening here:

  1. defer rows.Close() ensures cleanup when the function returns, including error paths.
  2. rows.Err() checks whether iteration ended because of an error.
  3. The connection can be returned to the pool once the result set is closed and the operation is finished.

A practical rule: Whenever you call Query() and receive *sql.Rows, close it.

Also, remember that QueryRow() behaves differently: it represents a single-row result and doesn't require you to call Close().

Problem 2: Not completing database transactions

Transactions are another common source of connection exhaustion.

Consider:

func UpdateUser(db *sql.DB) error { tx, err := db.Begin() if err != nil { return err } _, err = tx.Exec( "UPDATE users SET status = ? WHERE id = ?", "active", 10, ) if err != nil { return err } return nil } 

What's wrong here?

We started a transaction but never committed or rolled it back.

A transaction holds its underlying connection until it is completed.

If this happens repeatedly, connections can remain occupied and the pool can eventually become exhausted.

A safer pattern:

func UpdateUser(db *sql.DB) error { tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() _, err = tx.Exec( "UPDATE users SET status = ? WHERE id = ?", "active", 10, ) if err != nil { return err } if err := tx.Commit(); err != nil { return err } return nil } 

Why defer Rollback() if we're going to commit?

Because it provides cleanup for every early return.

After a successful commit, the deferred rollback simply returns an error indicating that the transaction has already completed. It's safe to ignore that result in this cleanup pattern.

The important thing is that every transaction should have a clearly defined completion path.

Never start a transaction and leave its lifecycle uncertain.

Problem 3: Starting another database operation inside a transaction

Here's a subtle issue that can be particularly confusing.

Imagine your pool has only one connection:

db.SetMaxOpenConns(1) 

Now consider:

func UpdateAndFetch(db *sql.DB) error { tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() _, err = tx.Exec( "UPDATE users SET status = ? WHERE id = ?", "active", 10, ) if err != nil { return err } var name string err = db.QueryRow( "SELECT name FROM users WHERE id = ?", 10, ).Scan(&name) if err != nil { return err } return tx.Commit() } 

Can you spot the problem?

The transaction already holds the only available connection.

But the next query uses db.QueryRow(), which tries to acquire a connection from the pool.

There are no available connections.

The transaction is waiting for the query to finish and the query is waiting for the transaction's connection to become available.

Neither can proceed.

This is a deadlock-like situation caused by exhausting the pool.

The fix is simple: execute queries that belong to the transaction through tx, not through db.

err = tx.QueryRow( "SELECT name FROM users WHERE id = ?", 10, ).Scan(&name) 

This is an especially important distinction:

  • db.Exec() / db.Query() acquire connections from the pool.
  • tx.Exec() / tx.Query() use the connection already reserved by that transaction.

Problem 4: Long-running queries

Not every pool exhaustion issue is caused by a connection leak.

Sometimes, connections are being released correctly, but database operations are taking too long.

Imagine:

  • Your pool has 20 connections.
  • 20 requests execute slow database queries.
  • Each query takes 15 seconds.
  • Another 100 requests arrive during that period.

Those new requests must wait for connections.

The application may appear unresponsive even though no connection has leaked.

Common reasons include:

  • Missing database indexes.
  • Queries scanning too many rows.
  • Unnecessary joins.
  • Lock contention.
  • Large result sets.
  • Database CPU or I/O saturation.
  • Slow network communication.

This is why increasing MaxOpenConns isn't always the solution.

If the database is already struggling, allowing more concurrent queries may make things worse.

The right approach is to investigate query execution time, database load and lock waits.

Problem 5: Queries without timeouts

Consider:

rows, err := db.Query( "SELECT id, name FROM users", ) 

What happens if the database takes too long to respond?

Without a suitable context deadline, the operation may wait longer than the caller can reasonably tolerate.

Use context-aware operations:

ctx, cancel := context.WithTimeout( context.Background(), 3*time.Second, ) defer cancel() rows, err := db.QueryContext( ctx, "SELECT id, name FROM users", ) if err != nil { return err } defer rows.Close() 

A context deadline gives the operation a time limit and allows cancellation to propagate through the database driver where supported.

Be careful, though: a timeout is not a substitute for closing rows or completing transactions.

You need both proper resource cleanup and sensible deadlines.

6. Let's reproduce connection pool exhaustion

Theory is useful, but let's actually see what happens.

We'll intentionally create a small pool and hold connections for a few seconds.

package main import ( "context" "database/sql" "fmt" "log" "time" ) func main() { db, err := sql.Open("postgres", "your-connection-string") if err != nil { log.Fatal(err) } defer db.Close() db.SetMaxOpenConns(2) db.SetMaxIdleConns(2) for i := 1; i <= 5; i++ { go func(id int) { ctx, cancel := context.WithTimeout( context.Background(), 10*time.Second, ) defer cancel() start := time.Now() conn, err := db.Conn(ctx) if err != nil { fmt.Printf( "Worker %d failed: %v\n", id, err, ) return } defer conn.Close() fmt.Printf( "Worker %d acquired connection in %v\n", id, time.Since(start), ) time.Sleep(5 * time.Second) fmt.Printf("Worker %d released connection\n", id) }(i) } time.Sleep(12 * time.Second) stats := db.Stats() fmt.Printf("Open: %d\n", stats.OpenConnections) fmt.Printf("In use: %d\n", stats.InUse) fmt.Printf("Idle: %d\n", stats.Idle) fmt.Printf("Wait count: %d\n", stats.WaitCount) fmt.Printf("Wait duration: %v\n", stats.WaitDuration) } 

Here, we're deliberately limiting the pool to two connections while starting five workers.

What should happen?

  1. The first two workers acquire connections.
  2. The remaining three workers wait.
  3. When one of the first workers closes its connection, a waiting worker can acquire it.
  4. The process continues until all workers finish or their contexts expire.

Notice this line:

defer conn.Close() 

It returns the reserved connection to the pool.

For this demonstration, we're using db.Conn() to reserve a connection explicitly. In normal application code, you generally use QueryContext, ExecContext, or a transaction and let database/sql manage connection acquisition.

Try changing the sleep duration, pool size and context timeout. You'll get a much better understanding of how pool contention behaves.

7. How to debug connection pool exhaustion in a real application

Now imagine this happens in production.

The API is slow, requests are timing out and the database pool is fully occupied.

Where do you start?

Don't immediately increase the connection limit or restart the application.

First, collect evidence.

Step 1: Check pool statistics

Go provides a very useful method:

stats := db.Stats() 

It returns a DBStats structure containing information about the pool.

Some important fields:

Field What it tells you
OpenConnections Currently open connections
InUse Connections currently checked out
Idle Connections currently idle
MaxOpenConnections Configured maximum
WaitCount Total number of waits for a connection
WaitDuration Total time spent waiting for connections

Let's log them:

func LogDBStats(db *sql.DB) { stats := db.Stats() log.Printf( "DB Pool | Open: %d | InUse: %d | Idle: %d | MaxOpen: %d | WaitCount: %d | WaitDuration: %v", stats.OpenConnections, stats.InUse, stats.Idle, stats.MaxOpenConnections, stats.WaitCount, stats.WaitDuration, ) } 

Call this periodically or expose these values through your application's metrics endpoint.

Now imagine you see:

DB Pool | Open: 25 | InUse: 25 | Idle: 0 | MaxOpen: 25 | WaitCount: 1200 | WaitDuration: 45s 

What does this tell us?

  • All 25 connections are currently occupied.
  • There are no idle connections available.
  • Other operations have been waiting for connections.
  • The pool is experiencing contention.

This is a strong indication that the pool is under pressure.

But don't jump to the conclusion that connections are leaking.

The same pattern can be caused by slow queries, long transactions, high request concurrency, or actual leaks.

Important: WaitCount and WaitDuration are cumulative counters. They aren't necessarily the number of requests waiting right now.

For meaningful monitoring, compare their values over time. For example, calculate the increase in WaitCount and WaitDuration over each monitoring interval.

Step 2: Check whether connections are being released

This is where you investigate the code.

Search for:

  • db.Query() without rows.Close().
  • Transactions without a commit or rollback.
  • Early returns before cleanup.
  • Long-running queries.
  • Database operations inside loops.
  • Connections reserved using db.Conn() without Close().
  • Rows that remain open while unrelated wo...