Company
About Tomosu
Platform
Platform & Agents Indexes How it works Solutions Pricing
Get Started
MCP Server VS Code — Plugin Installation Scan Your Repo — Guide Integrations · GitHub App Integrations · CodeRabbit MCP FAQ
Free Tools
Governance Impact
Resources
Blogs News Download / Free Trial Book a call →
Production Debugging · Database pools

Connection Leak vs. Pool Exhaustion: How to Tell the Difference

Tomosu AI·13 min read·

Every connection is checked out, requests are queuing, and the pool is throwing timeouts. The next decision depends on one question: is something leaking connections, or is the pool simply being asked for more than it can give? Those two problems produce the same error and need opposite fixes.

Quick answer

Pool exhaustion is a state: every pooled connection is checked out and new callers wait. A connection leak is one cause of it: code checks out a connection and never returns it. The fastest way to tell them apart is to watch in-use connections against traffic. A leak ratchets up and stays up; load-driven exhaustion falls when traffic falls.

This guide is language-agnostic. The mechanism is the same whether the pool is HikariCP on the JVM, pg.Pool in Node.js, SQLAlchemy’s QueuePool in Python, or Go’s database/sql. Where the tools differ, there is a per-stack table. For a JVM-specific walkthrough of HikariCP’s exception, leak detection, and thread dumps, see HikariCP “Connection Is Not Available, Request Timed Out”: How to Find the Real Cause.

What is the difference between a connection leak and pool exhaustion?

Pool exhaustion is the condition where every connection a pool is allowed to hold is checked out, so the next caller has to wait, and eventually times out. It is a symptom. A connection leak is a specific bug: a code path checks out a connection and never returns it to the pool, so the pool’s usable capacity shrinks by one each time that path runs. A leak always ends in exhaustion if the process lives long enough. Exhaustion does not imply a leak.

The other causes of exhaustion return every connection eventually, but not fast enough:

Search Stack Overflow for pool exhaustion and most threads end with “increase the pool size” or “you have a leak”, often without evidence for either. So the practical question is not “leak or exhaustion”. It is “leak, or one of the causes that recover on their own”. The answer changes the fix completely: a leak needs a code change on a specific path, while the others need faster work, shorter holds, less demand, or (last) more connections.

PropertyConnection leakExhaustion without a leak
Is every connection eventually returned?No. Leaked connections stay checked out until the process exitsYes, after the query, transaction, or remote call finishes
In-use when traffic dropsStays at its new highFalls back to a low baseline
Correlates withUptime, and the rate of one code path (often an error path)Request rate, query latency, dependency latency
Effect of a restartFixes it completely, until the leak catches up againLittle or none if the load or slowness persists
Effect of a bigger poolDelays the outage; does not prevent itHelps only if the pool is truly undersized

How do a leak and exhaustion look different over time?

At the moment of the timeout, both look identical: in-use equals the maximum, idle is zero, callers are waiting. The difference is only visible as a time series. Put checked-out (in-use) connections on the same chart as request rate, over at least a full daily cycle, and the two causes separate.

IN-USE CONNECTIONS OVER 24 HOURS · POOL MAX = 10 CONNECTION LEAK max 100 pinned while traffic falls restart steps up, never steps down EXHAUSTION FROM LOAD (NO LEAK) max 100 pool full at peak: callers wait recovers as traffic falls 00:0006:0012:0018:0024:00 in-use (checked-out) connections request rate (scaled)
Same error at the moment of failure, different shape over a day. Illustrative data.

Three details on that chart carry most of the diagnosis:

Autoscaling and deploys hide leaks

If instances are replaced often, by autoscaling or daily deploys, a slow leak may never reach the pool limit. It becomes visible when deploys pause over a holiday, or when a failing dependency makes the leaking path run far more often. Chart in-use per instance against instance uptime, not only the fleet total, or a leak can hide inside the average.

What does exhaustion look like in Java, Node.js, Python, and Go?

Every pool has a maximum size, a way to wait for a free connection, and some way to report how many connections are checked out. The defaults differ in ways that change what the incident looks like. In particular, two of the four common stacks do not fail fast by default.

StackPool size (default)Wait limit (default)
JDBC · HikariCPmaximumPoolSize (10)connectionTimeout (30 s)
Node.js · pg.Poolmax (10)connectionTimeoutMillis (0 = wait forever)
Python · SQLAlchemy QueuePoolpool_size (5) + max_overflow (10)pool_timeout (30 s)
Go · database/sqlSetMaxOpenConns (0 = unlimited)None; bounded only by the caller’s context
StackWhat exhaustion looks likeWhere to read in-use
JDBC · HikariCPSQLTransientConnectionException: “Connection is not available, request timed out”Pool stats in the message; hikaricp.connections.active; leakDetectionThreshold for borrower traces
Node.js · pg.PoolRequests hang. With a timeout set: “timeout exceeded when trying to connect”pool.totalCount, pool.idleCount, pool.waitingCount
Python · SQLAlchemyTimeoutError: “QueuePool limit of size 5 overflow 10 reached, connection timed out”engine.pool.status(), checkedout(); checkout and checkin pool events
Go · database/sqlGoroutines block, then context deadline exceeded. If unlimited, the database refuses new connections insteaddb.Stats(): InUse, Idle, WaitCount, WaitDuration

Defaults as documented by HikariCP, node-postgres, SQLAlchemy, and Go. Frameworks often override them, so read the effective values from your running configuration.

Two of those defaults deserve a closer look because they change the symptom, not just the timing.

node-postgres waits forever by default

With connectionTimeoutMillis at its default of 0, a caller that cannot get a client waits with no limit. An exhausted pool does not throw. Requests just stop completing, which often surfaces as a load balancer or gateway timeout far from the real cause. Set a timeout so the failure is visible and attributable, and export the three counters the pool already exposes:

Node.js · pg.Poolfails fast, observable
const pool = new Pool({
  max: 10,
  connectionTimeoutMillis: 5_000,   // default 0 means wait forever
  idleTimeoutMillis: 10_000,
});

setInterval(() => {
  metrics.gauge('db.pool.total', pool.totalCount);
  metrics.gauge('db.pool.idle', pool.idleCount);
  metrics.gauge('db.pool.waiting', pool.waitingCount);
  metrics.gauge('db.pool.in_use', pool.totalCount - pool.idleCount);
}, 10_000).unref();

Go’s database/sql is unlimited by default

With SetMaxOpenConns left at 0, a Go service never exhausts its own pool. A leak instead opens new connections until the database refuses them, for example PostgreSQL’s “sorry, too many clients already” or MySQL’s “Too many connections”. That error can hit every other service sharing the database, which makes a single service’s leak look like a platform-wide outage. Set a limit and log the stats:

Go · database/sqlbounded, observable
db.SetMaxOpenConns(20)             // default 0 = unlimited
db.SetMaxIdleConns(20)             // default 2
db.SetConnMaxLifetime(30 * time.Minute)

go func() {
    t := time.NewTicker(15 * time.Second)
    defer t.Stop()
    for range t.C {
        s := db.Stats()
        log.Printf("db pool open=%d inuse=%d idle=%d waits=%d waited=%s",
            s.OpenConnections, s.InUse, s.Idle, s.WaitCount, s.WaitDuration)
    }
}()

On any stack, the four numbers that matter are the same: maximum, in use, idle, and waiting (or cumulative wait count and time). If you only export one extra metric after reading this, make it in-use as a time series.

How do I confirm a leak from the database side?

The pool knows which connections are checked out. The database knows what each of those connections is actually doing. Comparing the two views is the quickest confirmation of a leak, and it works on any stack.

PostgreSQL · sessions by state
SELECT application_name, state, count(*) AS sessions,
       max(now() - state_change) AS longest_in_state
FROM   pg_stat_activity
WHERE  datname = current_database()
  AND  backend_type = 'client backend'
GROUP BY 1, 2
ORDER BY 3 DESC;

The column meanings are in the PostgreSQL cumulative statistics documentation. Set application_name in each service’s connection string so you can filter one service’s pool. Then line the result up against the pool’s own counters at the same moment.

SAME 10 CONNECTIONS, TWO VIEWS POOL SAYS in usein usein usein usein usein usein usein usein usein use in use 10, idle 0 DATABASE SEES activeactiveidle tx idleidleidleidleidleidleidle 2 / 1 / 7 2 ACTIVE Running a query right now. If most sessions look like this, suspect load or slow queries, not a leak. 1 IDLE IN TRANSACTION Transaction open, no query. App is doing other work, or leaked mid-transaction. Check its age. 7 IDLE, YET CHECKED OUT Borrowed from the pool but doing nothing, often for a long time. These are the leak candidates.
If the pool says “10 in use” and the database says “7 idle”, seven connections were borrowed and never given back. Illustrative numbers.
Pool saysDatabase saysMost likely cause
All in useMost sessions idle, state_change minutes or hours oldLeak with autocommit: connections borrowed and abandoned
All in useMany idle in transaction, old xact_startLeak mid-transaction, or a long hold around non-database work. Age tells them apart
All in useMost sessions active, long query_start ageSlow queries or a lock chain. Check wait_event_type
All in useMost sessions active, queries shortLoad: throughput × hold time exceeds the pool
Below max, callers waitingFewer sessions than expected; connection errors loggedCreation failure, such as the database’s connection limit reached by other clients

MySQL shows the same picture in SHOW FULL PROCESSLIST (Sleep with a large Time) and information_schema.innodb_trx.

A database timeout protects the database, not the pool

PostgreSQL’s idle_in_transaction_session_timeout ends sessions that sit in an open transaction too long, which releases their locks. It is a good guardrail. It does not return the connection to your pool: the pool still counts it as checked out until application code releases it, and the next use of that connection will fail. Treat it as damage control, not a leak fix.

Why does a leak show up during a different incident?

Most real leaks are not on the happy path. The happy path is exercised by every test and every request, so a missing release there is found within hours. Leaks survive on error and early-return paths: the branch taken when a row fails to parse, a downstream call throws, a validation check fails, or a request is cancelled. That has an important consequence: a leak drains the pool at the rate its path runs.

TIME TO DRAIN = POOL SIZE ÷ LEAK RATE NORMAL DAY Error path runs ~2 times an hour Pool of 20 ÷ 2 per hour ≈ 10 hours to drain DEPENDENCY STARTS FAILING Error path runs ~5 times a second Pool of 20 ÷ 5 per second ≈ 4 seconds to drain WHY NOBODY NOTICED Daily deploys restart the pool before it drains. Tests never take the error branch under load. WHAT ON-CALL SEES Pool exhaustion during an outage elsewhere. It looks like load. The pool stays full after the fix.
An error-path leak is invisible at a normal error rate and catastrophic at an incident error rate. Illustrative numbers.

This is why “the pool ran out during the payment provider outage” is often a misdiagnosis. The provider outage was real. The pool exhaustion was a second, pre-existing bug that the outage exposed. The tell is what happens after the dependency recovers: exhaustion from load or slowness clears within seconds, while a leak leaves the pool full until someone restarts the service.

A leak on an error path drains the pool at the error rate. Most of the time that is nearly zero. During an incident it is the whole pool.

The same logic explains leaks that appear “right after a deploy”. The deploy may not have added the leak. It may have added a new failure mode, a stricter validation, or a new timeout that sends more traffic down an old error branch. When you look for the commit that caused the incident, include changes that increase the rate of existing error paths, not only changes to the data access code.

Which code patterns leak connections?

The patterns are the same across languages: acquisition without a guaranteed release, and resources that pin a connection (result sets, cursors, streams, transactions) that are not closed on every path.

RELEASE ON EVERY PATH, OR LEAK ON ONE BROKEN acquire() query + map rows release() return ok throws Error propagates, release() is skipped: connection stays checked out forever FIXED acquire() try: query + map finally: release() exit Success and error both pass through finally: the connection always goes back.
The leak is a missing edge in the control flow. Structured release (try/finally, try-with-resources, with, defer) adds it for every path at once.

Node.js: client.release() skipped when a query throws

Node.js · pgleaks when a query throws
async function transfer(from, to, amount) {
  const client = await pool.connect();
  await client.query('BEGIN');
  await client.query(DEBIT_SQL, [from, amount]);   // may throw
  await client.query(CREDIT_SQL, [to, amount]);
  await client.query('COMMIT');
  client.release();                                  // never reached on error
}
Node.js · pgreleased on every path
async function transfer(from, to, amount) {
  const client = await pool.connect();
  let broken;
  try {
    await client.query('BEGIN');
    await client.query(DEBIT_SQL, [from, amount]);
    await client.query(CREDIT_SQL, [to, amount]);
    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK').catch((e) => { broken = e; });
    throw err;
  } finally {
    client.release(broken);   // always runs; truthy arg destroys it
  }
}

For single statements, pool.query() checks out and releases a client for you, so it cannot leak. The leak risk lives in code that calls pool.connect() directly, usually for transactions.

Go: rows or tx left open on an early return

Go · database/sqlleaks on a Scan error
rows, err := db.QueryContext(ctx, listItemsSQL, orderID)
if err != nil {
    return nil, err
}
for rows.Next() {
    var it Item
    if err := rows.Scan(&it.ID, &it.SKU, &it.Qty); err != nil {
        return nil, err   // rows still open: its connection stays in use
    }
    items = append(items, it)
}
return items, rows.Err()
Go · database/sqlclosed on every path
rows, err := db.QueryContext(ctx, listItemsSQL, orderID)
if err != nil {
    return nil, err
}
defer rows.Close()   // safe to call even after Next has returned false

tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback()  // no-op after a successful Commit

When rows.Next() returns false the rows are closed automatically, which is why the happy path in the broken example does not leak. Only the early return does, and only when a Scan fails. That is exactly the kind of path tests rarely exercise.

Python: a Session that is not closed on an exception

Python · SQLAlchemy 2.xmanual close context manager
# Broken: close() is skipped if anything between here and there raises
def load_invoice(invoice_id):
    session = SessionLocal()
    invoice = session.get(Invoice, invoice_id)
    render_pdf(invoice)                 # raises: session keeps its connection
    session.close()

# Fixed: the context manager closes the Session on every path
def load_invoice(invoice_id):
    with SessionLocal() as session:
        invoice = session.get(Invoice, invoice_id)
        render_pdf(invoice)             # connection returned when the block exits

In CPython, reference counting sometimes returns such a connection when the abandoned object is garbage collected, and SQLAlchemy may log a warning when it has to clean up a connection that was never checked in. That makes Python leaks intermittent rather than absent. Do not rely on it; frameworks such as FastAPI dependencies with yield, or scoped_session.remove() at request end, exist to make release structural.

Other patterns that pin a connection

For the JVM versions of these (unclosed Stream<T> repository results, REQUIRES_NEW nesting, and open-session-in-view), see the HikariCP guide and the sibling post on resource leaks in Java services.

Track the borrower when your pool will not

HikariCP has leakDetectionThreshold. node-postgres does not have a built-in equivalent, but you can add one in a few lines by recording where each client was checked out:

Node.js · minimal leak tracker
const held = new Map();

async function connect() {
  const client = await pool.connect();
  held.set(client, { at: Date.now(), stack: new Error('checked out here').stack });
  const release = client.release;
  client.release = (err) => { held.delete(client); return release(err); };
  return client;
}

setInterval(() => {
  const now = Date.now();
  for (const { at, stack } of held.values()) {
    if (now - at > 30_000) console.warn(`db client held ${now - at} ms`, stack);
  }
}, 10_000).unref();

As with HikariCP, a warning means “held a long time”, not “leaked”. A warning for a client that is later released is a long hold. A warning that repeats from the same stack and never clears is a leak. In SQLAlchemy, the pool’s checkout and checkin events give you the same hook.

A step-by-step way to tell a leak from pool exhaustion

  1. Read the error and the pool counters. Record maximum, in use, idle, and waiting at the moment of failure, and any chained cause. If total is below the maximum while callers wait, the pool is failing to create connections, which is a separate problem.
  2. Chart in-use against request rate over a full day. Per instance, against instance uptime. A ratchet that ignores quiet periods and resets on restart points to a leak; a curve that follows traffic points to load or slow work.
  3. Compare the pool’s view with the database’s. Many connections checked out in the pool but idle or long idle in transaction at the database confirms a leak. Mostly active sessions point to load or slow queries.
  4. Turn on borrower tracking. Use the pool’s leak detection, or a wrapper that records the checkout stack and age. Keep warnings that never clear; they name the leaking path.
  5. Find every acquisition path without structured release. Search for manual checkouts, transactions, returned cursors, and streams. Check the error and early-return branches, not just the happy path.
  6. Fix, then verify the shape. After the fix ships, in-use should return to its baseline in quiet periods and after the next dependency failure. If it still ratchets, there is a second path.

Fixes, matched to the cause

CauseFixWhat not to do
Connection leakRelease structurally on every path: try/finally, try-with-resources, with, defer. Close returned cursors and streams. Prefer single-call APIs such as pool.query() where possible.Raise the pool size or restart on a schedule. Both hide the leak until the error rate rises.
Leak mid-transactionRoll back on every non-commit path. Add idle_in_transaction_session_timeout as a guardrail for locks.Rely on the database timeout as the fix; the pool still thinks the connection is out.
Slow queriesFix the query plan or index; add a statement timeout so one query cannot hold a connection indefinitely.Raise the pool wait timeout. Users wait longer for the same failure.
Long holds around remote callsMove I/O out of the transaction and add client timeouts to the remote call.Add connections to match the slowest dependency.
Load or amplified demandReduce demand (cache, batch, bound retries), shed load, then size the pool from throughput × hold time within the database’s limit.Size each instance’s pool without multiplying by the instance count.
Undefined limitsSet a pool maximum and a wait timeout explicitly (Go SetMaxOpenConns, node-postgres connectionTimeoutMillis).Leave defaults that wait forever or open unlimited connections.

Treat any change to pool size, pool timeout, or data access code as a change to a shared production resource. Several services and background jobs usually share the database’s connection budget, so a local “fix” can move the exhaustion somewhere else. Assessing the blast radius of the change is part of the fix.

How Tomosu helps

Runtime metrics tell you that the pool is leaking. They rarely tell you every path that can leak, because a path only shows up in a leak trace after it has run in production. Tomosu analyzes the repository and each pull request for this kind of production reliability risk, and for connection handling it focuses on the code:

These findings feed the Production Reliability Index, so a pull request that adds a pool.connect() without a finally is visible at review time rather than during the next dependency outage.

Scan your repository with Tomosu →

Key takeaways

Frequently asked questions

What is the difference between a connection leak and pool exhaustion?

Pool exhaustion is a state: every connection in the pool is checked out and new callers wait or time out. A connection leak is one cause of it: code checks out a connection and never returns it. Exhaustion can also come from load, slow queries, long transactions, or nested acquisition, which recover when load drops. A leak does not recover until the process restarts.

How can I tell if my database connection pool is leaking?

Plot in-use (checked-out) connections against request rate over a day or more. If in-use steps up and stays up when traffic falls, and a restart resets it, you have a leak. Confirm by comparing the pool’s in-use count with what the database sessions are doing: leaked connections usually sit idle at the database while the pool counts them as busy.

Why does a connection leak only appear during errors or after a deploy?

Most leaks sit on an error or early-return path, so connections leak at the rate that path runs. When a dependency starts failing or a deploy adds a failure mode, that path runs far more often and the pool drains in minutes instead of days, which makes the leak look like a load problem.

Does increasing the pool size fix pool exhaustion?

Only when the pool is genuinely undersized: hold times are normal, in-use returns to baseline when traffic drops, and the database has spare connections across all instances. For a leak, a bigger pool only delays the outage. For slow queries or long transactions, it moves more concurrent work onto a database that is already struggling.

What happens when a node-postgres pool is exhausted?

By default, pool.connect() and pool.query() wait for a free client indefinitely, because connectionTimeoutMillis defaults to 0 (no timeout). Requests hang rather than fail. Set connectionTimeoutMillis so callers get the error “timeout exceeded when trying to connect”, and watch pool.totalCount, pool.idleCount, and pool.waitingCount.

How do connection leaks happen in Go database/sql?

The usual cause is a *sql.Rows that is not closed on every path, such as returning early on a Scan error without defer rows.Close(), or a *sql.Tx that is neither committed nor rolled back. Each holds a connection. db.Stats() shows InUse climbing and WaitCount growing once the SetMaxOpenConns limit is reached.

What does “QueuePool limit of size 5 overflow 10 reached” mean in SQLAlchemy?

It means all 15 connections allowed by the default QueuePool (pool_size 5 plus max_overflow 10) were checked out and a caller waited longer than pool_timeout, 30 seconds by default. It is pool exhaustion. Check that every Session is closed on every path, for example with a context manager, before raising pool_size.


A pool never runs out on its own. Some code path took a connection and did not give it back in time, and the fix lives on that path. Assess your repository →