Most PgBouncer incidents are the predictable consequence of a handful of internal mechanisms interacting under load: a single-threaded event loop, per-pool FIFO wait queues, fixed-size socket buffers, a small set of server connection states, and a pool mode that decides when server connections change hands. Hold these in your head and the alert thresholds stop being arbitrary numbers.
This article is the model, not the triage. It explains what PgBouncer is doing internally so that when cl_waiting spikes or sv_login won’t drain, you already know which part of the machine is hurting and why.
What it is and why it matters
PgBouncer is a single-threaded, event-driven connection multiplexer between application clients and PostgreSQL. Its entire purpose is to share a small pool of server connections across many client connections, because PostgreSQL backends are expensive and scarce (bounded by max_connections).
The consequence that bites: PgBouncer is itself a consumer of the scarce resource it manages. Every server connection it holds is one PostgreSQL slot. The sum of pool_size across all pools, times the number of PgBouncer instances pointing at the same backend, must fit inside max_connections with room left for superuser access, replication, and direct admin connections. A common planning rule is to keep PgBouncer’s total potential demand under 80% of the backend’s connection limit.
How it works
The event loop: one thread, one core
All I/O runs on a single-threaded libevent loop: every client socket, every server socket, every DNS lookup, every admin command. One thread, one CPU core. This is both the strength and the constraint.
The strength is overhead. An idle connection costs roughly 2KB, so a PgBouncer handling thousands of clients sits at tens of megabytes of RSS and typically under 5% of one core. The constraint is that anything slow and synchronous blocks everything: all pools, all clients, the admin console itself. TLS handshakes at high connection churn, SCRAM authentication storms, and pathological logging are the usual suspects. CPU saturation shows up on a single core while system-wide CPU looks idle, so per-process CPU is the only meaningful view.
Multi-core scaling means running multiple PgBouncer processes sharing a port via so_reuseport. Each process has independent pools and independent stats, so monitoring must aggregate across processes.
Pools: one per (database, user)
PgBouncer does not have “a pool”. It has one pool per unique (database, user) pair, each with its own server connections, its own wait queue, and its own pool_size. This is why aggregate dashboards lie: one saturated pool can be drowning clients while nine others idle, and the average looks green.
Server connection states
Within a pool, every server connection is in exactly one state, and the state names in SHOW POOLS map directly to where the connection is in its lifecycle:
| State | Meaning |
|---|---|
sv_active | Executing a query or transaction for a client |
sv_idle | Connected to PostgreSQL, available for immediate reuse |
sv_used | Idle, but unchecked for longer than server_check_delay; needs a health check before reuse |
sv_tested | Currently running server_check_query (default SELECT 1) |
sv_login | Authenticating with PostgreSQL right now |
sv_active_cancel / sv_being_canceled | Forwarding or completing a query cancel request |
The lifecycle reads: login -> active -> used -> tested -> idle -> back to active. Healthy pools are mostly sv_idle with bursts of sv_active. A pool stuck at high sv_login with low sv_active is failing to establish backend connections. Connections piling up in sv_used mean the check pipeline is slow.
The wait queue: where pain is measured
When all server connections in a pool are busy, clients enter a FIFO queue (cl_waiting). The age of the oldest waiter is exposed as maxwait (plus maxwait_us) in SHOW POOLS. This is the single most useful measurement PgBouncer gives you: queueing delay in wall-clock seconds, per pool, right now. If no server connection frees up within query_wait_timeout (default 120s), the client is disconnected with an error.
Two nuances matter. First, maxwait counts from when the query was sent, not when the client connected; a session-mode client can sit connected for hours with maxwait at zero. Second, if your application’s own timeout is shorter than query_wait_timeout, the application gives up and retries long before PgBouncer ejects the waiter, and each retry adds a new waiter. That retry amplification is how a slow patch becomes a full pool exhaustion cascade.
If reserve_pool_size > 0 (default 0, disabled), PgBouncer opens extra server connections beyond pool_size once a client has waited longer than reserve_pool_timeout (default 5s). This is overflow capacity for spikes. Sustained reserve usage means the base pool is undersized, not that the feature is working.
Socket buffers: where backpressure lives
Each connection has a pair of socket buffers sized by pkt_buf (default 4096 bytes). Data flows client sbuf -> parse/route -> server sbuf -> PostgreSQL, and back. When a result set exceeds pkt_buf, PgBouncer streams it in chunks, pausing the server read when the client write buffer fills. Nothing is zero-copy; every byte passes through user space. This is why very large result sets degrade pool capacity even when queries are “fast”: the server connection stays busy while PgBouncer dribbles rows to a slow client.
flowchart LR
subgraph clients["Application clients"]
c1["client 1"]
c2["client 2"]
cn["client N"]
end
subgraph pgb["PgBouncer (single thread, one core)"]
q["FIFO wait queue
(cl_waiting, maxwait)"]
pool["pool per (database, user)"]
s1["sv_active"]
s2["sv_idle / used / tested"]
s3["sv_login"]
pool --> s1
pool --> s2
pool --> s3
end
pg[("PostgreSQL
max_connections slots")]
c1 --> pool
c2 --> pool
cn --> q
q -->|pool full| pool
s1 --> pg
s2 --> pg
s3 --> pgPool modes: when server connections change hands
The pool mode is the most consequential configuration choice, because it defines when a server connection returns to the pool:
- Session (the default): the client holds its server connection for the entire session. Turnover is low, multiplexing benefit is minimal, and on return PgBouncer runs
server_reset_query(defaultDISCARD ALL) to scrub session state. That reset runs on every return and its time shows up insideavg_query_time. - Transaction: the server connection is returned after each transaction. Turnover is high and the multiplexing ratio is where the real wins are, but everything session-scoped breaks or silently misbehaves: temp tables,
SETvariables, advisory locks,LISTEN/NOTIFY, and prepared statements (unless you enablemax_prepared_statements, supported in recent PgBouncer versions). - Statement: returned after each statement. Breaks multi-statement transactions outright. Rarely appropriate.
Resources PgBouncer competes for
- File descriptors: one per client connection, one per server connection, plus listening sockets, the log file, pipe FDs, and admin sockets. The FD ceiling is a hard wall: at the OS limit, PgBouncer cannot accept new connections at all.
max_client_connmust be set with margin below the ulimit after accounting for all non-client FDs; a classic failure ismax_client_conn = 10000against a 1024 FD limit. - PostgreSQL slots: as above, pool demand must fit inside
max_connections. - CPU: one core. Fine until TLS termination or auth churn makes the loop the bottleneck.
- Memory: roughly 2KB per idle connection, more with full buffers and substantially more with TLS state.
Where it shows up in production
The failure archetypes all fall out of the machinery above:
- Pool exhaustion cascade. All server connections busy,
cl_waitinggrows,maxwaitclimbs past application timeouts, retries deepen the queue. The most common PgBouncer incident, and almost always rooted in either slow backend queries (avg_query_timeup) or connections held too long (idle-in-transaction:avg_xact_timemuch larger thanavg_query_time). - Client connection limit.
max_client_connreached; new connections refused immediately withno more connections allowed (max_client_conn). No queueing, just refusal. Frequently the FD limit in disguise. - FD exhaustion. Same wall, one layer down. At the limit PgBouncer cannot accept connections and basic operations start failing.
- Backend unreachable. PostgreSQL down, network partition, or stale DNS. Signature:
sv_loginelevated, total server connections declining,cl_waitinggrowing. PgBouncer’s own DNS cache (checkSHOW DNS_HOSTS) can keep it dialing a dead primary until the TTL expires. - Pool mode mismatch. Application relies on session state in transaction mode. Every PgBouncer metric looks healthy; only application error logs (“prepared statement does not exist”, missing temp tables) reveal it. The most insidious failure because it impersonates an application bug.
- Connection leak. Sessions that never release (long idle transactions in session mode, abandoned clients) exhaust the pool under light load.
- Administrative state.
PAUSEstops new query routing while existing transactions finish (cl_waitingspikes,sv_activedrains to zero);DISABLErefuses new clients while existing ones keep working. Looks exactly like an outage on the metrics, so every PgBouncer alert should checkpaused/disabledfromSHOW DATABASESbefore escalating.
Tradeoffs and when this matters
The central tradeoff is pooling efficiency versus session semantics. Transaction mode gives you the multiplexing ratio that justifies running PgBouncer at all, at the cost of every session-scoped feature. Session mode is safe for anything but barely reduces connection counts. Teams switch to transaction mode for efficiency and discover the semantic cost during the next incident, because PgBouncer has no metric for “your session state just vanished”.
The second tradeoff is the single thread. You get very low per-connection overhead and no lock contention, and in exchange you accept that one stalled synchronous operation freezes every pool at once, and that scaling past one core means multiple processes with independent state.
Third: server_reset_query correctness versus latency. DISCARD ALL on every session-mode return is what makes connection reuse safe; it is also real work on the backend that hides inside avg_query_time and looks like “the database is slow”.
Finally, the instrumentation contract: SHOW commands give you snapshots and aggregates, and nothing else. There are zero error counters in the admin console. Authentication failures, connection refusals, and timeout events exist only in the log file. Stats are rolling averages that smooth sub-period spikes, cumulative counters reset on restart, and column positions in SHOW STATS shift between versions, so reference columns by name.
Signals to watch in production
Per-pool, always; aggregates hide the drowning pool.
| Signal | Why it matters | Warning sign |
|---|---|---|
cl_waiting (SHOW POOLS) | Clients blocked on a server connection; the primary saturation signal | Any value sustained > 60s, database not paused |
maxwait / maxwait_us (SHOW POOLS) | Age of the oldest waiter; user-facing pain in seconds | > 5s impacting; > 15s likely causing app failures |
sv_active vs pool_size | Pool utilization; the leading indicator before queueing starts | > 85% sustained; 100% means the next request queues |
avg_wait_time (SHOW STATS_AVERAGES) | Latency PgBouncer itself injects, distinct from database time | > 100ms sustained |
avg_query_time vs avg_xact_time | Separates “backend slow” from “connections held idle in transaction” | avg_xact_time » avg_query_time |
sv_login (SHOW POOLS) | Backend connection establishment health | Sustained > 0 with sv_idle draining |
used_clients vs max_client_conn | Proximity to the hard client refusal wall | > 80% sustained |
| Process CPU and FD count | Single-core saturation and the real connection ceiling | CPU > 70% of one core; FDs > 80% of ulimit |
The correlation that shortens almost every incident: avg_wait_time high with avg_query_time low means the pool is too small or connections are held too long. avg_wait_time high with avg_query_time high means PostgreSQL is the root cause and PgBouncer is the messenger. Check both before touching either.
How Netdata helps
- Netdata’s PgBouncer collector queries the admin console directly and charts
cl_waiting,maxwait, and the fullsv_active/sv_idle/sv_used/sv_tested/sv_loginbreakdown per pool, so you see which(database, user)pool is saturated instead of an aggregate that hides it. - It plots
avg_wait_timealongsideavg_query_timeandavg_xact_time, exactly the pairing needed to attribute latency to the pool, the backend, or idle-in-transaction clients. - Per-process CPU and file descriptor usage for the PgBouncer process are collected from the host at per-second resolution, so event-loop saturation and FD-approaching-ulimit show up on the same dashboard as pool metrics.
- Because PgBouncer exposes no error counters, pairing the metrics with log-based alerts on
no more connections allowed,query_wait_timeout, and login failure strings closes the blind spot theSHOWcommands leave open. - Anomaly detection on
cl_waitingandmaxwaitper pool catches slow-building saturation (growing transaction times eating headroom) before the queue crosses alert thresholds.






