Your alert fired on sv_active / pool_size > 85%, or you spotted the ratio creeping up on a dashboard. No clients are waiting yet. cl_waiting is zero. Latency looks normal. This is exactly the moment this signal exists for: it is the last cheap warning you get before the pool goes over the cliff.

PgBouncer pool saturation is not a gradual degradation. Below 100% utilization, client wait time is approximately zero because server connection assignment is instant. At 100%, the next client request has nowhere to go and enters a FIFO queue with unbounded wait. There is no “slow but working” middle state. The ratio of sv_active to pool_size tells you how close you are to that edge, and it moves before cl_waiting, maxwait, and avg_wait_time show anything.

This article covers what the ratio means, why it degrades as a cliff, which checks to run, and how to decide whether to act now or just watch.

What this means

PgBouncer maintains one server connection pool per (database, user) pair. sv_active counts server connections currently linked to a client, executing a query or holding a transaction. pool_size (from SHOW DATABASES) is the cap on server connections for that pool. When sv_active equals pool_size, the pool is full: the next client request that needs a server connection must wait.

The invariant that governs the pool (absent reserve pool use) is:

sv_active + sv_idle + sv_used + sv_tested + sv_login <= pool_size

sv_active is the consumed capacity. sv_idle is the ready reserve: connections connected to PostgreSQL and immediately usable. The other states are transient inventory (recently returned connections awaiting reuse, connections being tested, connections logging in). When sv_active rises, sv_idle shrinks. When sv_idle hits zero and sv_active hits pool_size, queuing begins.

The operating bands:

RatioStateAction
< 70%Healthy headroomNone. Baseline capacity planning only.
70-85%WatchTrend it. Identify what is consuming connections.
> 85% sustainedActDiagnose the cause before the pool fills.
100% with waitersIncidentSee PgBouncer pool exhaustion.
flowchart LR
  A[sv_active / pool_size rising] --> B{ratio?}
  B -->|under 70%| C[healthy headroom]
  B -->|70-85%| D[watch: find the consumer]
  B -->|over 85% sustained| E[act: diagnose cause]
  B -->|100% sustained| F[cl_waiting grows, maxwait climbs]
  F --> G[app timeouts, retry cascade]

Two properties of this signal matter more than the number itself.

Per-pool beats aggregate. Pools are per (database, user). One pool at 100% while five others idle produces a global average that looks fine. If you chart sum(sv_active) / sum(pool_size) across pools, a saturated pool is invisible. Always alert and chart per pool, not on the global aggregate.

The pool mode changes what the ratio means. In transaction pooling mode, server connections are acquired and released per transaction, so sv_active fluctuates rapidly. Brief spikes to 1.0 under burst traffic are normal; sustained 1.0 with cl_waiting > 0 is the incident. In session mode, sv_active reflects active sessions and moves slowly, so a rising ratio there is a capacity trend, not noise.

Common causes

CauseWhat it looks likeFirst thing to check
Slow backend queriessv_active rising, avg_query_time above baseline, connections held longer per querySHOW STATS_AVERAGES query_time vs baseline
Long or idle-in-transaction holdsavg_xact_time much larger than avg_query_time, sv_active high with modest query rateRatio of xact_time to query_time; PostgreSQL pg_stat_activity for idle in transaction
Traffic growthsv_active tracking query rate up over days or weeks, everything else normalSHOW STATS_AVERAGES query_count trend
Undersized pool_sizeRatio routinely above 85% at normal peak, occasional brief cl_waiting blipsPeak sv_active vs pool_size over a week
Connection leak (session mode)sv_active climbing without matching traffic, connections never releasedSHOW SERVERS connect_time and request_time on active connections
Cold start or recycling wavesv_login elevated, pool churning, ratio temporarily highSHOW POOLS sv_login; recent restart or server_lifetime boundary

The two you most need to separate are “the database got slower” and “the pool got too small.” They produce the same sv_active curve but opposite fixes. That is what avg_query_time and avg_wait_time are for; see the diagnosis steps.

Quick checks

All read-only, run against the admin console.

# Per-pool snapshot: sv_active, sv_idle, cl_waiting, maxwait
psql -h /var/run/postgresql -p 6432 -U pgbouncer pgbouncer -Atc "SHOW POOLS;"

# Configured pool_size and administrative state (paused / disabled) per database
psql -h /var/run/postgresql -p 6432 -U pgbouncer pgbouncer -Atc "SHOW DATABASES;"

# Backend latency and wait latency, per database
psql -h /var/run/postgresql -p 6432 -U pgbouncer pgbouncer -Atc "SHOW STATS_AVERAGES;"

# Per-connection detail: which server connections are old
psql -h /var/run/postgresql -p 6432 -U pgbouncer pgbouncer -Atc "SHOW SERVERS;"

Notes on reading these:

  • In SHOW POOLS unaligned output on PgBouncer 1.21+, cl_waiting is the 4th field, sv_active the 7th, sv_login the 13th, maxwait the 14th. Older versions lack the cancel-related fields, which shifts these positions. Column positions in SHOW STATS are also version-dependent (1.23 added server_assignment_count, 1.24 added prepared statement counters). Prefer SHOW STATS_AVERAGES and reference columns by name, not position.
  • paused = 1 or disabled = 1 in SHOW DATABASES changes the meaning of everything else. During PAUSE, cl_waiting spikes and sv_active drains to zero by design. Check this first before escalating.
  • A single SHOW POOLS is a point-in-time snapshot. Sample it a few times over a minute before drawing conclusions about “sustained.”

How to diagnose it

  1. Identify the pool. From SHOW POOLS, find which (database, user) rows have sv_active near pool_size. If you only track the aggregate, stop and fix that first; per-pool is where this signal lives.

  2. Confirm it is sustained, not a burst. In transaction mode, brief 1.0 spikes are normal. Sample SHOW POOLS several times over 1-5 minutes. Sustained high ratio, or a ratio that has been trending up over days, is what warrants action.

  3. Split wait time from query time. From SHOW STATS_AVERAGES, compare query_time and wait_time for the affected database:

    • query_time elevated above baseline: the backend is slow. Connections are held longer, so the pool fills. The fix is on PostgreSQL, not PgBouncer.
    • query_time normal, ratio still high: the pool is undersized for current concurrency, or transactions are holding connections without querying.
  4. Check for idle-in-transaction holds. If xact_time is much larger than query_time, clients are holding server connections while doing non-database work. In transaction mode this is the most common silent cause of high sv_active. Cross-check on PostgreSQL:

    # Find idle-in-transaction backends holding pool connections
    psql -h <postgres-host> -U <user> -d <db> -c \
      "SELECT pid, state, now() - xact_start AS xact_age, query
       FROM pg_stat_activity
       WHERE state = 'idle in transaction'
       ORDER BY xact_age DESC;"
    
  5. Trace the hold to a client. In SHOW SERVERS, active connections have a link column pointing at the client using them, and request_time showing how long the current work has run. Follow the link into SHOW CLIENTS to get the source address of the application holding the connection.

  6. Check headroom explicitly. Look at sv_idle for the pool. sv_idle = 0 with cl_waiting = 0 is the “looks green, is actually yellow” state: no one is waiting, but the next request when all connections are active will queue. Zero idle at peak means you are one slow query from an incident.

  7. Check reserve pool usage if configured. If reserve_pool_size > 0, compare total server connections in the pool (sv_active + sv_idle + sv_used + sv_tested + sv_login) against pool_size. Totals above pool_size, or "taking connection from reserve_pool" in the log, mean the base pool is already overflowing. Reserve is for brief spikes; sustained use means pool_size is chronically undersized.

Metrics and signals to monitor

SignalWhy it mattersWarning sign
sv_active / pool_size per poolLeading saturation indicator; moves before clients queue>85% sustained for >5 minutes
sv_idle per poolReady reserve; zero means no headroom0 sustained, or trending toward 0 over days
cl_waiting per poolClients blocked now; the trailing confirmation of saturationAny sustained nonzero with maxwait growing
maxwait per poolAge of the oldest waiter; user-facing pain>5s noticeable, >15s likely app failures
avg_wait_time (SHOW STATS) or wait_time (SHOW STATS_AVERAGES)Pooling-induced latencySustained above zero; >100ms is serious
avg_query_time (SHOW STATS) or query_time (SHOW STATS_AVERAGES)Backend speed as seen by PgBouncer>2x baseline: root cause is PostgreSQL
avg_xact_time vs avg_query_timeGap measures idle-in-transaction hold timexact_time much larger than query_time
Reserve pool usageOverflow engaged; base pool undersizedTotal server connections > pool_size, or reserve log lines
paused / disabled per databaseContext that inverts alert meaningSuppress other alerts when set

Fixes

Backend is slow (avg_query_time elevated)

The pool is a symptom, not the cause. Fix the query or the database: missing indexes, lock contention, I/O saturation. Canceling a single long-running query on PostgreSQL (pg_cancel_backend) can free a held pool connection, but coordinate with the application team first and treat it as a tactical move, not the fix. Do not grow pool_size to compensate for a slow database; that multiplies load against an already struggling backend and consumes more max_connections slots on PostgreSQL.

Idle-in-transaction holds

Fix the application pattern: do not hold BEGIN open across HTTP calls or computation. As a guardrail, PostgreSQL’s idle_in_transaction_session_timeout can auto-terminate these, which releases the PgBouncer server connection as a side effect, but clients receive an error, so roll it out deliberately.

Pool genuinely undersized

Raise pool_size for the affected database (per-database override or default_pool_size) and RELOAD. Check two budgets before you do:

  • The sum of all pools’ pool_size (plus reserve, if configured) across every PgBouncer instance targeting this PostgreSQL must fit inside max_connections with room for direct connections, replication, and admin access. Keep PgBouncer capacity under about 80% of max_connections.
  • File descriptors: each server connection is an FD. Confirm the process is nowhere near its limit (compare Max open files in /proc/<pid>/limits against ls /proc/<pid>/fd | wc -l).

Do not shrink the pool because sv_idle is high

Idle server connections in transaction mode are the ready reserve, not waste. Reducing pool_size because sv_idle “looks unused” removes burst capacity and moves the cliff closer. The correct response to high idle with low waiting is nothing, or slower capacity planning, not a smaller pool.

Prevention

  • Alert per pool on the ratio, with duration. sv_active / pool_size > 85% sustained for 5 minutes, per (database, user). Do not alert on the global aggregate, and do not alert on instantaneous spikes: transaction-mode bursts hit 1.0 legitimately.
  • Gate alerts on administrative state. Every PgBouncer saturation alert must check paused/disabled in SHOW DATABASES and suppress during maintenance. High cl_waiting during PAUSE is expected behavior.
  • Trend headroom, not just utilization. Chart sv_idle per pool over weeks. A linear trend toward zero gives you runway: (pool_size - current sv_active) / daily growth rate is roughly your days-to-queuing estimate.
  • Pair wait time and query time on every dashboard. They are the attribution pair. One tells you the pool is too small, the other tells you the database is too slow. Never look at either alone.
  • Audit applications for transaction-mode hazards. Idle-in-transaction patterns and long multi-statement transactions are application bugs that present as pool saturation. avg_xact_time / avg_query_time is the cheap detector.
  • Size against PostgreSQL first. Pool capacity planning is constrained by max_connections on the backend, not by how many connections PgBouncer can technically hold.

How Netdata helps

  • Per-pool sv_active vs pool_size as a ratio chart, so the 70/85/100% bands are visible per (database, user) rather than hidden in a global average.
  • cl_waiting and maxwait on the same timeline as the ratio, so you can see the leading indicator (ratio rising) and the trailing confirmation (waiters appearing) in one view and judge how much runway you have.
  • avg_wait_time next to avg_query_time, which is the attribution split: pooling-induced latency versus backend latency. This is the correlation that prevents the wrong fix.
  • sv_idle trending, turning “zero headroom at peak” from an incident discovery into a capacity ticket weeks earlier.
  • Anomaly detection on per-pool utilization, which catches the single-pool-at-100% case that aggregate dashboards and static thresholds on averages miss.
  • Paused/disabled state collected alongside saturation metrics, giving alerts the maintenance context they need to avoid paging during a planned PAUSE.