Observing Connection Pool Exhaustion

Connection pool exhaustion has one of the most misleading signatures in backend performance: every query in the trace is fast, CPU is low, the database reports itself healthy, and requests are slow. The waiting happens in the call that acquires a connection, before any query span starts, so query-level instrumentation shows nothing at all. This page covers measuring acquisition, hold time and the pool's own counters, and finding what is holding connections too long — which is almost always the real problem. It is a task article under database and I/O performance observability, part of the Python profiling and performance observability section.

What is actually holding the connections A pool of five connections is drawn with each connection checked out by a request. Each of those requests ran a four millisecond query early on, then called an external payment service taking eight hundred milliseconds while still holding the connection inside an open transaction, then committed. The database sees five idle-in-transaction connections and reports no load. Behind the pool, new requests queue waiting for a connection; their eventual query takes four milliseconds but their acquisition wait is hundreds of milliseconds. The trace of a waiting request shows a long gap before a fast query. The note records that the pool is not too small — each connection is busy for four milliseconds of every eight hundred it is held — and that the fix is to release the connection before the external call. five connections, each held for 800 ms, used for 4 ms conn 1 held during external payment call — idle in transaction conn 2 conn 3 conn 4 conn 5 green: the query clay: held, unused a new request waiting to acquire — no span, no query yet 4 ms query the pool is not too small — each connection works 4 ms of every 800 it is held release the connection before the external call and five connections serve two hundred times the load
The database sees idle connections and reports no load. The application sees fast queries. The waiting is in between, and hold time explains it.

Prerequisites

pip install "sqlalchemy>=2.0.0,<3.0.0" \
            "prometheus-client>=0.20.0,<1.0.0" \
            "opentelemetry-api>=1.27.0,<2.0.0"

Implementation

Step 1 — Time acquisition as its own span and histogram. The pool's checkout is the step to measure, and it must be measured from the caller's side, because the pool's own events fire after the wait is over. Wrapping connection acquisition gives both a span — so the wait appears in the trace as a named interval instead of a gap — and a histogram for alerting.

import time
from contextlib import contextmanager

from opentelemetry import trace
from prometheus_client import Histogram

ACQUIRE = Histogram(
    "db_pool_acquire_seconds", "Time waiting for a pooled connection", ["pool"],
    buckets=(0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5))
tracer = trace.get_tracer(__name__)


@contextmanager
def connection(engine, pool_name: str = "main"):
    started = time.perf_counter()
    with tracer.start_as_current_span("db.pool.acquire") as span:
        conn = engine.connect()
        waited = time.perf_counter() - started
        span.set_attribute("db.pool.wait_ms", round(waited * 1000, 2))
    ACQUIRE.labels(pool_name).observe(waited)
    try:
        yield conn
    finally:
        conn.close()                    # returns it to the pool

Step 2 — Measure hold time. How long each connection stays checked out is the number that usually explains exhaustion. A pool of ten connections serving requests that hold them for four milliseconds supports thousands of requests per second; the same pool serving requests that hold them for eight hundred supports about twelve. The pool's checkout and checkin events bracket exactly that interval.

from sqlalchemy import event
from prometheus_client import Gauge, Histogram

HOLD = Histogram("db_pool_hold_seconds", "Checkout to checkin", ["pool"],
                 buckets=(0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10))
IN_USE = Gauge("db_pool_in_use", "Connections checked out", ["pool"])
SIZE = Gauge("db_pool_size", "Configured pool size plus overflow", ["pool"])

SIZE.labels("main").set(engine.pool.size() + engine.pool._max_overflow)

@event.listens_for(engine, "checkout")
def _on_checkout(dbapi_conn, record, proxy):
    record.info["checked_out_at"] = time.perf_counter()
    IN_USE.labels("main").set(engine.pool.checkedout())

@event.listens_for(engine, "checkin")
def _on_checkin(dbapi_conn, record):
    started = record.info.pop("checked_out_at", None)
    if started is not None:
        HOLD.labels("main").observe(time.perf_counter() - started)
    IN_USE.labels("main").set(engine.pool.checkedout())

Step 3 — Chart in-use against the ceiling. The two gauges on one chart turn exhaustion into a line touching its limit. An average hides it: a pool that is saturated during every peak and idle overnight averages to a comfortable number.

Step 4 — Count timeouts and overflow. SQLAlchemy's pool raises a timeout error when acquisition exceeds its pool_timeout, and creates overflow connections beyond the pool size up to max_overflow. Both are the pool announcing it is under pressure. Counting timeouts is essential, since they are errors users see; counting overflow is the early warning, because overflow happens well before timeouts do.

Expected Output: the four signals during an exhaustion episode.

db_pool_size{pool="main"}                 15
db_pool_in_use{pool="main"}               15
db_pool_acquire_seconds p99               1.84
db_pool_hold_seconds p99                  0.82
db_pool_timeouts_total                    37

In use at the ceiling, acquisition near two seconds, and hold time of eight hundred milliseconds for queries that take a few — the combination says the pool is being held rather than used.

Step 5 — Attribute hold time to code paths. Knowing hold time is long is half the diagnosis; knowing which route holds connections longest is the other half. Recording hold time on the current span, or labelling the histogram with a bounded route template, identifies the handler. The usual culprits are a transaction opened at the start of a request and committed at the end, with slow non-database work in between — an external API call, template rendering, a file upload.

The fix is usually a shorter hold Two versions of the same request handler. In the first, a database transaction is opened at the start of the request, a query runs, an external payment call runs for eight hundred milliseconds, a second query runs, and the transaction commits; the connection is held for the whole eight hundred and twenty milliseconds. In the second, the first query runs and the connection is released, the external call runs without holding any connection, and a second short transaction performs the final write; the connection is held for two intervals totalling eleven milliseconds. The same pool of fifteen connections can now serve roughly seventy times as many concurrent requests. The note records that the queries are identical in both versions and only their arrangement around the slow call changed. same queries, different arrangement transaction around everything connection held 820 ms external payment call · 800 ms release before the slow call external payment call · no connection held connection held 11 ms in total the same 15 connections now serve roughly 70 times as many concurrent requests no pool setting changed
Moving a slow call outside the transaction is usually worth more than any pool setting, because it changes how long each connection is unavailable.

Sizing the pool once holds are short

Once hold time reflects actual database work, the pool can be sized from arithmetic rather than guesswork, and the arithmetic has two constraints that pull in opposite directions.

The first is demand. By Little's law, the average number of connections in use equals the rate of checkouts multiplied by the average hold time. A service making two hundred checkouts per second with a hold time of ten milliseconds uses two connections on average. The pool needs headroom above the average for bursts, and a common target is keeping peak usage around seventy percent of the pool size.

The second is the database's capacity, which is shared. Every replica of every service contributes its pool size, plus overflow, to the total connection count the database must accept. Forty replicas with pools of fifteen and overflow of ten can open a thousand connections, which exceeds the default limit of many database servers and would degrade the database well before reaching it. The per-service pool therefore has to fit a fleet-level budget, and raising it to fix one service's exhaustion can cause connection failures in every other service.

Where the two constraints conflict — the demand genuinely needs more connections than the budget allows — the answer is a connection pooler in front of the database, which multiplexes many application connections onto fewer server connections. That moves the constraint rather than removing it, and the same measurements apply at the pooler.

The order of operations that avoids most of these problems is: measure hold time, shorten it, then size the pool from the new numbers. Doing it in the other order — enlarging the pool first — hides the long holds, consumes the database's shared budget, and leaves the underlying problem to reappear at the next increase in traffic.

Async pools and the invisible queue

Async database drivers change one aspect of this picture, and it makes exhaustion harder rather than easier to see.

A synchronous pool that is exhausted blocks the calling thread, so the waiting is visible as threads parked in the pool's acquisition path — a wall-clock profile shows it, and a thread count climbing towards the server's limit shows it. An async pool that is exhausted suspends the calling coroutine instead. No thread is consumed and nothing is parked in a way a profiler would notice; the event loop continues serving other coroutines, and the waiting ones accumulate silently in the pool's internal queue.

That makes the acquisition span from step 1 even more important, since it is often the only evidence. It also makes a second measurement worthwhile: the number of coroutines currently waiting for a connection. Most async pools expose it or can be wrapped to track it, and a count that climbs during peaks is the direct equivalent of the queue wait in observing thread pool saturation.

There is one further async-specific hazard. A coroutine that acquires a connection and then awaits something slow — an HTTP call, a message queue, another coroutine — holds that connection across the await, exactly as a synchronous handler holds it across a blocking call. The async version is easier to write accidentally, because an await inside an async with block looks harmless. The hold time histogram catches it the same way.

Pool capacity is a rate, not a count A bar chart of the request rate a pool of 20 connections can serve, depending on how long each request holds a connection. At 5 milliseconds held per request, the pool supports about 4000 requests per second. At 20 milliseconds, about 1000. At 100 milliseconds, about 200. At 500 milliseconds, for example when an HTTP call happens while a transaction is open, about 40. The note says capacity equals pool size divided by hold time, so halving the hold time doubles capacity just as doubling the pool would, without adding load to the database. requests/s a pool of 20 can serve, by hold time held 5 ms ~4 000 req/s held 20 ms ~1 000 req/s held 100 ms ~200 req/s held 500 ms (HTTP in txn) ~40 req/s capacity = pool size ÷ hold time halving the hold time doubles capacity without adding load to the database
A pool's throughput depends as much on how long connections are held as on how many there are.

Configuration options

Setting / metric Recommended Why
pool_size from demand × hold time, with headroom steady-state capacity
max_overflow small, and watched overflow is an early warning
pool_timeout a few seconds fail rather than wait indefinitely
pool_pre_ping on avoids handing out dead connections
Acquire histogram always the exhaustion signal
Hold histogram always the usual cause
In-use vs size gauges on one chart saturation as a line meeting a ceiling

Verification

Confirm the change by comparing hold time and acquisition wait under the same load.

histogram_quantile(0.99, rate(db_pool_hold_seconds_bucket{pool="main"}[10m]))
histogram_quantile(0.99, rate(db_pool_acquire_seconds_bucket{pool="main"}[10m]))

Expected Output: hold time falling to roughly the query time, and acquisition wait falling to near zero.

before  hold p99 0.82 s   acquire p99 1.84 s
after   hold p99 0.011 s  acquire p99 0.0004 s

Common mistakes

Instrumenting only queries. Error signature: slow requests whose traces contain a long unexplained gap before fast queries. Root cause: the wait is in acquisition, which has no span. Remediation: wrap acquisition as in step 1.

Enlarging the pool first. Error signature: exhaustion returning at the next traffic increase, and other services failing to connect. Root cause: long holds hidden rather than fixed, and the database's shared budget consumed. Remediation: measure and shorten hold time before resizing.

Transactions spanning slow external work. Error signature: hold time far above query time, and idle-in-transaction connections on the database. Root cause: a transaction opened at request start and committed at the end. Remediation: release connections before external calls and reopen for the final write.

Averaging the in-use gauge. Error signature: a pool that looks half-used and times out every afternoon. Root cause: averages hide peaks. Remediation: chart the gauge at full resolution against the ceiling.

No timeout. Error signature: requests hanging for tens of seconds during database trouble. Root cause: acquisition waiting indefinitely. Remediation: set pool_timeout so callers fail quickly and the failure is counted.

Frequently Asked Questions

Why are my queries fast but my requests slow?

If the time is not in the queries, it is often in acquiring a connection to run them. When every pooled connection is checked out, new requests wait before their first query starts, and a trace that only records query spans shows that wait as an unexplained gap.

Should I just increase the pool size?

Not first. A pool is usually exhausted because connections are held too long — across a slow external call, during template rendering, or for a whole request in a transaction. Shortening the hold time fixes the cause; enlarging the pool moves the pressure to the database, whose connection limit is shared by every service.

What is a healthy acquisition time?

Effectively zero — a small fraction of a millisecond. A healthy pool hands out an idle connection immediately. Any sustained acquisition wait of more than a few milliseconds means the pool is at or near its limit.

What does overflow mean in SQLAlchemy's pool?

Connections created beyond the configured pool size, up to the overflow limit, and discarded when returned. Frequent overflow means the steady-state pool is too small for the load or connections are held too long; both are worth investigating before raising either number.