Diagnosing psycopg_pool PoolTimeout Errors

This guide is part of psycopg 3 & asyncpg Connection Pools. PoolTimeout means one thing precisely: a caller asked the pool for a connection and did not get one within timeout seconds. It does not say why, and the four possible causes need opposite fixes — a pool that is too small, connections held by code that is not using them, queries that got slower, or a pool that cannot open new connections at all:

Traceback (most recent call last):
  File "/app/billing/invoices.py", line 88, in charge_due_invoices
    with pool.connection() as conn:
  File "/usr/local/lib/python3.12/site-packages/psycopg_pool/pool.py", line 200, in connection
    conn = self.getconn(timeout=timeout)
psycopg_pool.PoolTimeout: couldn't get a connection after 3.00 sec

The objective is to identify which of the four it is from the pool’s own statistics and the database’s view, then apply the fix that matches — and to leave behind the metrics that make the next occurrence obvious.

Rapid incident diagnosis

Take a statistics snapshot while it is happening. get_stats() is cheap and safe to call from a signal handler, an admin endpoint or a debug thread:

s = pool.get_stats()
print({k: s.get(k) for k in (
    "pool_min", "pool_max", "pool_size", "pool_available", "requests_waiting",
    "requests_num", "requests_queued", "requests_wait_ms", "requests_errors",
    "usage_ms", "connections_num", "connections_ms", "connections_errors", "connections_lost")})

Three derived numbers decide the diagnosis:

mean_hold_ms    = usage_ms ÷ requests_num
mean_wait_ms    = requests_wait_ms ÷ max(requests_queued, 1)
connect_ms      = connections_ms ÷ max(connections_num, 1)

Then look at the same moment from PostgreSQL:

SELECT state,
       count(*)                                        AS sessions,
       max(now() - state_change)                       AS longest_in_state,
       max(now() - query_start) FILTER (WHERE state = 'active') AS longest_query
FROM pg_stat_activity
WHERE application_name = 'billing-worker'
GROUP BY state;

Match the pattern.

Evidence Cause Fix
pool_size = pool_max, sessions active, longest_query short, mean_hold_ms unchanged from baseline Pool too small for current load Raise max_size within the database budget
pool_size = pool_max, sessions active, longest_query seconds long, mean_hold_ms risen Queries got slower Fix the query or index; pool size is not the issue
pool_size = pool_max, sessions idle or idle in transaction Connections held without being used Move network calls out of the with pool.connection() block
pool_size < pool_max, connections_errors rising, connect_ms high Pool cannot open new connections Fix authentication, DNS, TLS or the server’s max_connections
requests_errors rising without requests_waiting max_waiting rejections, not timeouts Shed load upstream; check whether max_size is right

Check for a stalled reconnect. If connections_errors climbs and pool_size falls towards zero, the pool is failing to reconnect. psycopg_pool retries with backoff until reconnect_timeout, then calls reconnect_failed. Without that callback wired to an alert, the service looks healthy while holding no connections at all.

Decision path for a psycopg_pool PoolTimeout Start from whether the pool reached max_size. If it did, server session state distinguishes an undersized pool, slower queries and held connections. If it did not, connection errors point to a pool that cannot open connections. PoolTimeout after timeout s pool_size = pool_max? yes · active, fast queries: too small yes · active, slow queries: query time yes · sessions idle: connections held no · connection errors: cannot connect pool_available and requests_waiting confirm the queue
Four causes, one exception. The pool's own numbers separate capacity from hold time, and the server's session states separate working connections from idle ones.

Mathematical sizing or parameter formula

Once the cause is capacity, size the pool from measured demand rather than guesswork. The pool’s counters contain everything needed.

in_flight_average = usage_ms ÷ elapsed_ms          (connections busy on average)
max_size          = ceil( in_flight_average ÷ target_utilisation )

Worked example. Over a 60-second window, requests_num rose by 24,000 and usage_ms by 270,000. Average in-flight work is 270000 ÷ 60000 = 4.5 connections. At a 70 % target, max_size = ceil(4.5 ÷ 0.7) = 7. The pool was configured at 4, which is below the average demand — the queue could never drain, which is why waits grew until they hit the timeout.

The same numbers say what the timeout should be. Mean wait among queued requests was requests_wait_ms ÷ requests_queued; if that is already close to timeout, the queue is not draining and raising the timeout only lengthens failure. If it is far below, the timeout is doing its job.

For the held-connection case, the useful figure is hold time against query time:

hold_overhead = mean_hold_ms − mean_query_ms

With a mean hold of 220 ms and mean query time of 12 ms from pg_stat_statements, 208 ms of every checkout is application work. Cutting that — by moving an HTTP call out of the block — reduces demand by the same factor as multiplying the pool size, without any extra load on the database.

Check the fleet before raising anything:

processes × max_size ≤ usable_connections_for_this_service

Sixteen worker processes at max_size = 7 claim 112 connections. If the service’s share of the database is 80, the pool cannot grow to 7 everywhere; either reduce process count, cut hold time, or put PgBouncer in front.

Deriving pool size from usage statistics Over a 60 second window the pool's usage_ms implies 4.5 connections busy on average, while the configured max_size of 4 sits below it, so the queue never drains. A max_size of 7 provides headroom. Connections busy on average versus configured maximum in flight 4.5 configured max_size 4: below demand proposed max_size 7 (70 % target) a maximum below average demand guarantees a growing queue, whatever the timeout is
Average in-flight connections come straight from usage_ms over the sample window. The configured maximum has to sit above that line with headroom, not on it.

Exact remediation & configuration

If the pool is too small, raise it at runtime to stop the errors, then persist the change:

pool.resize(min_size=4, max_size=7)      # takes effect immediately

Check the fleet arithmetic before doing this on every process, and confirm PostgreSQL has free slots:

SELECT current_setting('max_connections')::int - count(*) AS free_slots
FROM pg_stat_activity;

If connections are held, shorten the block. The pattern that causes it is a network call inside with pool.connection():

# Before: the connection is held for the whole HTTP round trip
with pool.connection() as conn:
    invoice = conn.execute("SELECT * FROM invoices WHERE id = %s", (invoice_id,)).fetchone()
    receipt = gateway.charge(invoice["total"])          # 180 ms, no database work
    conn.execute("UPDATE invoices SET paid_at = now(), receipt = %s WHERE id = %s",
                 (receipt.id, invoice_id))

# After: two short checkouts, nothing held during the call
with pool.connection() as conn:
    invoice = conn.execute("SELECT * FROM invoices WHERE id = %s", (invoice_id,)).fetchone()

receipt = gateway.charge(invoice["total"], idempotency_key=str(invoice_id))

with pool.connection() as conn:
    conn.execute("UPDATE invoices SET paid_at = now(), receipt = %s WHERE id = %s AND paid_at IS NULL",
                 (receipt.id, invoice_id))

The idempotency key makes the split safe: if the process dies between the call and the update, retrying the charge does not double-bill.

If queries got slower, find them and fix the query rather than the pool:

SELECT query, calls, mean_exec_time, max_exec_time
FROM pg_stat_statements
WHERE userid = (SELECT oid FROM pg_roles WHERE rolname = 'billing_app')
ORDER BY mean_exec_time * calls DESC
LIMIT 10;

If the pool cannot open connections, the fix is outside the pool: credentials, TLS, DNS, security groups or the server’s connection ceiling. Make the failure loud so it is never mistaken for saturation:

def reconnect_failed(pool):
    log.critical("pool %s has no connections; reconnect failed for %ss", pool.name, pool.reconnect_timeout)
    metrics.pool_reconnect_failed.labels(pool.name).inc()

Make the timeout explicit per call site where it differs. A background job can afford to wait longer than a request handler:

with pool.connection(timeout=10.0) as conn:    # nightly reconciliation
    reconcile(conn)

Preventing recurrence

Two habits stop the same incident repeating: alert on the leading indicator, and keep hold time visible.

Alert on queued share and errors, not on pool_size. A pool at its maximum is working, not failing; a pool whose callers wait is heading for timeouts:

groups:
  - name: psycopg-pool
    rules:
      - alert: PsycopgPoolTimeouts
        expr: rate(psycopg_pool_requests_errors_total[5m]) > 0
        for: 2m
        labels: { severity: critical }
        annotations:
          summary: "{{ $labels.pool }}: PoolTimeout or TooManyRequests raised"
      - alert: PsycopgPoolQueueingHigh
        expr: rate(psycopg_pool_requests_queued_total[5m])
              / rate(psycopg_pool_requests_total[5m]) > 0.05
        for: 10m
        labels: { severity: warning }
        annotations:
          summary: "{{ $labels.pool }}: more than 5 % of requests wait for a connection"

Track mean hold time as a first-class series. A rise in usage_ms ÷ requests_num without a matching rise in database query time is application work creeping inside checkouts — the cause that no amount of pool sizing fixes. Reviewing it after each release catches it while the change is still fresh.

Keep one canary assertion in the load test. Run the service’s peak load in staging with the production pool settings and assert that requests_errors stays at zero and the queued share stays under a few percent. A change that breaks either — a new query in a hot path, a slower dependency inside a checkout — then fails before release.

Signal Leading or lagging What it tells you
requests_queued ÷ requests_num Leading Callers are starting to wait
requests_wait_ms ÷ requests_queued Leading How close waits are to the timeout
usage_ms ÷ requests_num Leading Whether hold time is creeping up
requests_errors Lagging Users are already failing
connections_lost, connections_errors Either Network or server-side problems
Leading and lagging pool signals during an incident The share of queued requests rises first, mean wait follows, and PoolTimeout errors appear last, so alerting on the queued share provides warning before requests fail. Order in which the signals move t0 t0 + 20 min queued share rises mean wait approaches timeout PoolTimeout raised warning warning page alert on the first two; the third is the user-visible failure and by then requests are already being lost
The leading signals move before anyone sees an error. Alerting on the queued share buys time that alerting on timeouts does not.

Validation & verification

Reproduce the diagnosis. In staging, force each cause and confirm the statistics look as the table predicts: cap max_size at 1 under load (pool too small); add a time.sleep(0.5) inside a checkout (connections held); run a deliberately slow query (query time); point the pool at a wrong password (cannot connect). This builds confidence that the next production snapshot can be read correctly.

Confirm the fix under the original load. Replay the traffic that caused the incident at the same rate. requests_errors must stay at zero, pool_size must peak below max_size, and mean wait must stay well under the timeout:

s = pool.pop_stats()
assert s.get("requests_errors", 0) == 0, s
assert s.get("requests_wait_ms", 0) / max(s.get("requests_queued", 1), 1) < 200, s

Verify the database side did not simply move the queue. After raising max_size, PostgreSQL’s Threads-equivalent view should show more sessions but similar query times:

SELECT count(*) AS sessions,
       count(*) FILTER (WHERE state = 'active') AS active
FROM pg_stat_activity WHERE application_name = 'billing-worker';

If active sessions rose and query times rose with them, the database — not the pool — is now the constraint, and the extra connections should be given back.

Frequently Asked Questions

Should I just raise the timeout?
Only if waits are short and occasional and the caller can afford to wait. A longer timeout on a pool whose maximum is below average demand converts fast failures into slow ones without serving more requests.
Why does pool_available show connections while callers time out?
A brief snapshot can catch the pool between a return and the next handout. Sustained availability with waiting callers is unusual and suggests the waiters are blocked elsewhere — for example, on check_connection round trips to an unresponsive server.
Does PoolTimeout roll back anything?
No transaction was ever started, because no connection was obtained. Nothing needs cleanup; the caller simply has no connection and must retry or fail.
Can I see which code paths hold connections longest?
Wrap pool.connection() in a helper that records the elapsed time per call site, or use an OpenTelemetry span around acquisition and use, as described in Tracing Connection Acquisition Latency with OpenTelemetry Spans.