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:

StateMeaning
sv_activeExecuting a query or transaction for a client
sv_idleConnected to PostgreSQL, available for immediate reuse
sv_usedIdle, but unchecked for longer than server_check_delay; needs a health check before reuse
sv_testedCurrently running server_check_query (default SELECT 1)
sv_loginAuthenticating with PostgreSQL right now
sv_active_cancel / sv_being_canceledForwarding 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 --> pg

Pool 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 (default DISCARD ALL) to scrub session state. That reset runs on every return and its time shows up inside avg_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, SET variables, advisory locks, LISTEN/NOTIFY, and prepared statements (unless you enable max_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_conn must be set with margin below the ulimit after accounting for all non-client FDs; a classic failure is max_client_conn = 10000 against 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:

  1. Pool exhaustion cascade. All server connections busy, cl_waiting grows, maxwait climbs past application timeouts, retries deepen the queue. The most common PgBouncer incident, and almost always rooted in either slow backend queries (avg_query_time up) or connections held too long (idle-in-transaction: avg_xact_time much larger than avg_query_time).
  2. Client connection limit. max_client_conn reached; new connections refused immediately with no more connections allowed (max_client_conn). No queueing, just refusal. Frequently the FD limit in disguise.
  3. FD exhaustion. Same wall, one layer down. At the limit PgBouncer cannot accept connections and basic operations start failing.
  4. Backend unreachable. PostgreSQL down, network partition, or stale DNS. Signature: sv_login elevated, total server connections declining, cl_waiting growing. PgBouncer’s own DNS cache (check SHOW DNS_HOSTS) can keep it dialing a dead primary until the TTL expires.
  5. 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.
  6. Connection leak. Sessions that never release (long idle transactions in session mode, abandoned clients) exhaust the pool under light load.
  7. Administrative state. PAUSE stops new query routing while existing transactions finish (cl_waiting spikes, sv_active drains to zero); DISABLE refuses new clients while existing ones keep working. Looks exactly like an outage on the metrics, so every PgBouncer alert should check paused/disabled from SHOW DATABASES before 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.

SignalWhy it mattersWarning sign
cl_waiting (SHOW POOLS)Clients blocked on a server connection; the primary saturation signalAny 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_sizePool 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_timeSeparates “backend slow” from “connections held idle in transaction”avg_xact_time » avg_query_time
sv_login (SHOW POOLS)Backend connection establishment healthSustained > 0 with sv_idle draining
used_clients vs max_client_connProximity to the hard client refusal wall> 80% sustained
Process CPU and FD countSingle-core saturation and the real connection ceilingCPU > 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 full sv_active/sv_idle/sv_used/sv_tested/sv_login breakdown per pool, so you see which (database, user) pool is saturated instead of an aggregate that hides it.
  • It plots avg_wait_time alongside avg_query_time and avg_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 the SHOW commands leave open.
  • Anomaly detection on cl_waiting and maxwait per pool catches slow-building saturation (growing transaction times eating headroom) before the queue crosses alert thresholds.