Monitoring PgBouncer Pool Saturation with Prometheus
Problem statement: Application connections to your read replicas start timing out with query_wait_timeout, but the replicas themselves look healthy β the bottleneck is the PgBouncer pool in front of them, and without Prometheus visibility into cl_waiting, sv_active, and maxwait you cannot see the saturation building until clients are already failing.
Symptom Identification
Pool saturation announces itself through the application and the pooler long before the database shows any stress:
- Application logs fill with
server closed the connection unexpectedlyor driver-level connection-acquisition timeouts, while replica CPU and lag remain nominal. SHOW POOLSin the PgBouncer admin console showscl_waitingclimbing above zero and staying there, withsv_activepinned atdefault_pool_size.maxwaitβ the seconds the oldest waiting client has been blocked β creeps toward your configuredquery_wait_timeout.- Latency p99 rises on read endpoints even though per-query execution time on the replica is flat, because the time is being spent waiting for a backend, not running the query.
The tell that distinguishes this from a database problem is that replica-side metrics are healthy. The queue is forming in the pooler, and only pooler metrics reveal it.
Root Cause Analysis
PgBouncer multiplexes many client connections onto a bounded set of backend server connections per pool. When every server connection in a pool is checked out, additional client requests are parked in a wait queue until a backend is released. Three counters describe this exactly:
cl_waitingβ clients currently blocked waiting for a server connection. Zero in a healthy pool; any sustained non-zero value means demand exceedsdefault_pool_size.sv_activeβ server connections currently assigned to a client and doing work. This rises todefault_pool_sizeunder load and stays there; on its own it is a utilisation signal, not a saturation signal.maxwaitβ how long, in seconds, the oldest waiting client has been queued. This is the direct precursor toquery_wait_timeouterrors: whenmaxwaitreaches the timeout, clients start being dropped.
Saturation is the combination of sv_active at the pool ceiling and cl_waiting above zero. High sv_active alone is a fully-utilised pool serving traffic efficiently β the desired state at peak. The mistake teams make is alerting on utilisation and paging on every busy period, or conversely watching only the database and missing that the pool is the constraint.
The exporterβs job is to lift these counters out of the transient SHOW POOLS snapshot and into a time series you can rate, quantile, and alert on. This sits inside the broader Prometheus metrics for replica health and lag instrumentation, and the pool it observes is configured per configuring PgBouncer for read-only connection pools.
Architecture: Where the Queue Forms
Step-by-Step Resolution
Step 1: Expose the admin database to a stats user
PgBouncer serves its counters from a virtual database named pgbouncer. Grant a dedicated stats account access without full admin rights.
# pgbouncer.ini
[pgbouncer]
stats_users = stats_collector ; can run SHOW commands, cannot RELOAD/SHUTDOWN
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txtAdd the stats user to userlist.txt and reload:
kill -HUP $(pgrep pgbouncer)Inline verification: psql -p 6432 -U stats_collector pgbouncer -c "SHOW POOLS;" returns a row per pool with cl_active, cl_waiting, sv_active, and maxwait columns.
Step 2: Run pgbouncer_exporter
pgbouncer_exporter \
--pgBouncer.connectionString="postgres://stats_collector@localhost:6432/pgbouncer?sslmode=disable" \
--web.listen-address=:9127Inline verification:
curl -s localhost:9127/metrics | grep -E 'pgbouncer_pools_(client_waiting|server_active)|maxwait'You should see per-database, per-user series such as pgbouncer_pools_client_waiting_connections{database="appdb_ro"}.
Step 3: Scrape and label the pooler
# prometheus.yml
scrape_configs:
- job_name: pgbouncer
scrape_interval: 5s
static_configs:
- targets: ['pgb-1:9127']
labels: {role: pooler, cluster: orders, az: use1-az1}
- targets: ['pgb-2:9127']
labels: {role: pooler, cluster: orders, az: use1-az2}Inline verification: up{job="pgbouncer"} returns 1 for each pooler in the Prometheus expression browser.
Step 4: Compute the saturation PromQL
# Fraction of the pool that is actively serving, per pool
pgbouncer_pools_server_active_connections
/ on(instance, database, user) group_left
(pgbouncer_config_default_pool_size)
# Backpressure: clients currently queued
pgbouncer_pools_client_waiting_connections
# Oldest client wait in seconds (the direct timeout precursor)
pgbouncer_pools_client_maxwait_secondsInline verification: under synthetic load (e.g. pgbench -c 100), the saturation ratio should approach 1.0 and client_waiting should rise while the ratio is pinned.
Step 5: Alert before clients time out
# rules/pgbouncer_saturation.yml
groups:
- name: pgbouncer_saturation
rules:
- alert: PgBouncerPoolSaturated
expr: |
pgbouncer_pools_client_waiting_connections > 0
and pgbouncer_pools_server_active_connections
>= pgbouncer_config_default_pool_size
for: 1m
labels: {severity: warning}
annotations:
summary: "Pool {{ $labels.database }} on {{ $labels.instance }} saturated: clients queueing"
- alert: PgBouncerClientWaitHigh
# query_wait_timeout defaults to 120s; page at half of it
expr: pgbouncer_pools_client_maxwait_seconds > 60
for: 30s
labels: {severity: critical}
annotations:
summary: "Oldest client on {{ $labels.database }} waited {{ $value }}s for a backend"Inline verification: silence-test by lowering the threshold temporarily and confirming the alert transitions to firing in the Prometheus Alerts tab.
Configuration Snippet
A complete recording-rule block that turns the raw counters into a single saturation series dashboards can read cheaply:
# rules/pgbouncer_recording.yml
groups:
- name: pgbouncer_recording
interval: 15s
rules:
- record: pgbouncer:pool_saturation_ratio
expr: |
pgbouncer_pools_server_active_connections
/ clamp_min(pgbouncer_config_default_pool_size, 1)
- record: pgbouncer:client_wait_p99_seconds
expr: |
max by (instance, database) (pgbouncer_pools_client_maxwait_seconds)
- record: pgbouncer:avg_query_seconds:rate5m
expr: |
rate(pgbouncer_stats_total_query_time_seconds[5m])
/ clamp_min(rate(pgbouncer_stats_total_query_count[5m]), 1)Verification and Rollback
Confirm the pipeline end to end and prove the alert fires under real saturation:
# Drive load through the pool and watch the queue form
pgbench -h localhost -p 6432 -U app_read_user -d appdb_ro -c 120 -T 60 -S &
# In parallel, watch the raw counters
watch -n1 'psql -p 6432 -U stats_collector pgbouncer -c "SHOW POOLS;"'While the load runs, pgbouncer:pool_saturation_ratio should approach 1.0 and PgBouncerPoolSaturated should move to pending, then firing.
If the exporter or a bad rule destabilises Prometheus, roll back the rule file and reload:
git checkout HEAD -- rules/pgbouncer_saturation.yml rules/pgbouncer_recording.yml
curl -X POST http://localhost:9090/-/reloadIf the saturation itself is the emergency, the immediate mitigation is to raise default_pool_size β but only within the replicaβs max_connections budget, or you trade a pooler queue for remaining connection slots are reserved errors on the replica.
Edge Cases and Gotchas
maxwait resets, so rate() lies on it
maxwait is the wait time of the current oldest waiting client; the moment that client is served, the value drops to whatever the next-oldest clientβs wait is, or to zero. It is a gauge with sawtooth behaviour, not a monotonic counter, so never wrap it in rate() or increase(). Alert on its instantaneous value with a for: duration to require the condition to persist, and use max_over_time(pgbouncer_pools_client_maxwait_seconds[5m]) if you want the worst wait in a window.
Per-pool cardinality with many databases
pgbouncer_exporter emits one series per (database, user) pool. A pooler fronting hundreds of database aliases or users produces a series set that multiplies across every counter. Keep the alias list lean, and if you must front many databases, drop the low-value per-pool counters at the scrape and keep only client_waiting, server_active, and maxwait. This is the same cardinality discipline described in the replica health metrics overview.
Session pooling hides transient saturation
In pool_mode = session, a backend is pinned to a client for the entire session, so a saturated pool clears only as sessions disconnect β which can be minutes. A brief cl_waiting spike you would shrug off in transaction mode signals a much longer stall in session mode. If your read pool runs session mode, lower the PgBouncerClientWaitHigh threshold and treat any sustained cl_waiting as urgent, because the queue drains slowly.
FAQ
What is the single best PgBouncer saturation signal?
Client waiting connections, cl_waiting, is the clearest signal. It counts clients that have asked for a server connection and are blocked because every backend in the pool is busy. A steady non-zero cl_waiting means default_pool_size is too small for the offered load, and it precedes the query_wait_timeout errors your application will see. Pair it with maxwait, which reports how long the oldest waiting client has been blocked in seconds.
Why is sv_active near pool size but cl_waiting still zero?
That is a fully utilised but not yet saturated pool: every server connection is doing useful work and clients are being served without queueing. It is the healthy high-load state. Saturation only begins when sv_active is pinned at the pool size AND cl_waiting rises above zero, meaning new clients cannot get a backend. Alert on the combination, not on high sv_active alone, or you will page on normal peak traffic.
Does transaction pooling change which metrics matter?
Yes. In transaction mode a backend is returned to the pool after every transaction, so sv_active turns over rapidly and a small pool serves many clients. Waiting is therefore driven by transaction duration, not session lifetime, and maxwait spikes point to slow queries on the replica rather than too many idle sessions. In session mode a backend is pinned for the whole client session, so cl_waiting reflects concurrent session count and a saturated pool clears much more slowly.
Related
β Back to Prometheus Metrics for Replica Health and Lag
- Configuring PgBouncer for Read-Only Connection Pools β how to size
default_pool_sizeand pick a pool mode for the pool you are monitoring here. - postgres_exporter Custom Queries for Replication Lag β the companion guide for the replicas sitting behind this pool.
- Connection Pool Architecture for Read Replicas β the pool-sizing model that determines when saturation is a config problem versus a capacity problem.
- Monitoring & Observability for Read Replicas β the parent section covering exporters, dashboards, and alerting for the whole replica fleet.