PgBouncer exists to reduce the number of connections PostgreSQL has to hold, so it is easy to assume that adding PgBouncer makes the connection limit problem go away. It does not. PgBouncer is itself a consumer of PostgreSQL connection slots, and its worst-case demand is arithmetic you control in config files: one pool per (database, user) pair, each pool allowed to open pool_size server connections, plus reserve_pool_size overflow, multiplied by every PgBouncer instance pointing at the same backend.

When that worst-case sum exceeds what PostgreSQL can accept, the failure does not show up at steady state. It shows up during the burst: a deploy, a retry storm, a cold start, a batch job. PgBouncer tries to open new server connections, PostgreSQL refuses them with FATAL: sorry, too many clients already, and the pool drains while clients queue. This is a static configuration audit, not a runtime mystery.

This article gives you the arithmetic, the audit procedure, and the signals that tell you the mismatch is about to bite.

The arithmetic that matters

The sum of (pool_size + reserve_pool_size) across every pool, on every PgBouncer instance targeting one PostgreSQL, must stay under max_connections minus superuser_reserved_connections, minus anything else that connects directly.

Written out:

PostgreSQL budget  =  max_connections
                    - superuser_reserved_connections
                    - direct application clients
                    - replication connections
                    - admin / ops access headroom

PgBouncer demand   =  SUM over all instances of:
                        SUM over all (database, user) pools of:
                          pool_size + reserve_pool_size

Requirement        =  PgBouncer demand <= ~80% of max_connections

The ~80% target leaves room for everything that is not PgBouncer: psql sessions during incidents, monitoring, migrations, logical replication, and the superuser connections you will need when the backend is full. If PgBouncer demand can reach 100% of max_connections, then at the exact moment you most need to connect and fix things, you cannot get in.

Three properties of PgBouncer make this sum easy to get wrong:

  • Pools multiply by (database, user) pairs, not by database. pool_size is per pool, and a pool exists per unique (database, user) combination. Four users against two databases at default_pool_size of 20 is up to 160 server connections, not 40.
  • Per-database and per-user overrides hide. A database stanza can override pool_size, and max_db_connections / max_user_connections cap totals across pools. The effective ceiling per instance is not just default_pool_size from SHOW CONFIG.
  • Multiple instances multiply demand silently. Each PgBouncer process, including so_reuseport siblings and sidecars, has independent pools. Two instances each sized “safely” at 60% of max_connections are collectively at 120%.
flowchart TD
  subgraph pgb1[PgBouncer instance 1]
    p1a[pool db1/app1: pool_size + reserve]
    p1b[pool db1/app2: pool_size + reserve]
    p1c[pool db2/app1: pool_size + reserve]
  end
  subgraph pgb2[PgBouncer instance 2]
    p2a[pool db1/app1: pool_size + reserve]
    p2b[pool db2/app2: pool_size + reserve]
  end
  budget[PostgreSQL max_connections]
  reserved[minus superuser_reserved_connections]
  direct[minus direct clients and replication]
  headroom[~20 percent safety headroom]
  p1a --> budget
  p1b --> budget
  p1c --> budget
  p2a --> budget
  p2b --> budget
  budget --> reserved --> direct --> headroom

Every pool on every instance draws from the same budget. The audit question is whether the sum of the left side fits inside what remains after the right side is subtracted.

What the failure looks like

The failure mode is “backend connection failure with PostgreSQL max_connections reached as the root cause”:

  1. A burst hits: deploy, cold start, retry storm, or a batch job. Many pools want to grow toward pool_size simultaneously.
  2. PostgreSQL hits max_connections and refuses new logins. Clients connecting directly to PostgreSQL see FATAL: sorry, too many clients already. PgBouncer server connections fail with S: login failed in the PgBouncer log.
  3. Inside PgBouncer, sv_login rises or fluctuates (connections attempting and failing), sv_idle declines, and total server connections stop growing even though demand is unmet.
  4. Existing server connections keep working until they expire (server_lifetime) or error out, so the pool drains gradually rather than dying instantly.
  5. cl_waiting grows, maxwait climbs, and clients start hitting query_wait_timeout (default 120s) or their own application timeouts and retry, which deepens the queue.

The distinguishing feature versus ordinary pool exhaustion: in ordinary exhaustion, total server connections are stable at pool_size and all are busy. In this failure, the total server connection count is declining or stuck below pool_size while PostgreSQL reports itself full. PgBouncer has headroom on paper and cannot spend it.

One nasty property: this failure is triggered by the burst, not by steady state. Everything looks fine at 40% utilization for months, right up until the day every pool wants its full pool_size at once. That is why it has to be caught by the static audit, not by watching dashboards.

How the mismatch creeps in

  • New users or databases added without re-running the sum. Each new (database, user) pair is a whole new pool worth of potential connections.
  • A second PgBouncer instance deployed for HA or rollout, sized against the full max_connections as if it were alone.
  • reserve_pool_size treated as free. Reserve connections are real PostgreSQL connections. They must be in the sum.
  • pool_size set to 0 to mean “use the default” out of habit. Since PgBouncer 1.24.0, a pool_size of 0 is documented to mean unlimited, which removes the per-pool ceiling entirely; older versions treated 0 differently. Check your version before assuming 0 is safe.
  • max_db_connections and max_user_connections left at 0 (unlimited), so nothing caps runaway demand across pools for a hot database or user.
  • PostgreSQL max_connections lowered during a “right-sizing” exercise while PgBouncer configs were left alone.

Auditing the arithmetic

This is a read-only procedure. Run it per PostgreSQL instance, and re-run it after any change to users, databases, PgBouncer instance count, or pool configuration.

1. Get the PostgreSQL budget.

# On PostgreSQL itself
psql -Atc "SHOW max_connections;"
psql -Atc "SHOW superuser_reserved_connections;"

# Current usage for context
psql -Atc "SELECT count(*) FROM pg_stat_activity;"

2. Get per-database pool configuration from every PgBouncer instance.

# Run against each PgBouncer instance's admin console
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -Atc "SHOW DATABASES;"

SHOW DATABASES gives you per-database pool_size, reserve_pool_size, and max_connections (the per-database cap, 0 means unlimited). This is the authoritative view because it includes per-database overrides of default_pool_size.

3. Enumerate the actual pools.

psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -Atc "SHOW POOLS;"

Each row is one (database, user) pool. Caveat: pools are created on demand. SHOW POOLS shows pairs that have connected; a configured user that has not connected yet has no pool row but will create one under load. For the worst case, count the (database, user) combinations your applications can use, not just the ones currently visible.

4. Compute worst-case demand per instance.

For each instance, for each possible pool: min(pool_size + reserve_pool_size, max_db_connections if set, max_user_connections if set). Sum across pools. Then sum across all instances targeting this PostgreSQL.

5. Compare against the budget.

demand <= 0.8 * max_connections
and demand <= max_connections - superuser_reserved_connections - direct_clients - replication_slots_in_use

If either check fails, you are one burst away from login refusals. Also sanity-check SHOW CONFIG for default_pool_size: if it is 0 and you run PgBouncer 1.24.0 or newer, that may mean unlimited, and the per-pool ceiling is gone.

Signals to watch

The audit is the primary defense, but these runtime signals tell you the ceiling is being approached or has been hit:

SignalWhy it mattersWarning sign
PostgreSQL connection count vs max_connectionsThe actual budget consumptionSustained above 80% of max_connections
sv_login per pool (SHOW POOLS)Server connections stuck or failing in the login phasePersistently > 0 with cl_waiting growing
Total server connections vs pool_sizeWhether PgBouncer can reach its configured ceilingTotal declining or pinned below pool_size during a burst
cl_waiting and maxwait (SHOW POOLS)Client impact once server connections cannot be establishedcl_waiting > 0 sustained, maxwait > 15s
PgBouncer log: S: login failedDirect evidence of backend login refusals (log-only, no SHOW counter exists)Any occurrence during load
PostgreSQL log: FATAL: sorry, too many clients alreadyThe limit itself being hitAny occurrence
Reserve pool activationOverflow being drawn, meaning base pools are at ceilingUsage sustained beyond brief spikes

PgBouncer has no error counters in its SHOW commands. The login refusal itself is only visible in logs, so the metric signals (sv_login, cl_waiting, pool totals) are your leading indicators and the logs are confirmation.

Keeping demand under the limit

Once the audit shows over-subscription, the levers, roughly in order of preference:

  • Shrink pool_size to match real concurrency. Most pools need far fewer server connections than their defaults suggest. Use sv_active history: if a pool never exceeds 8 active connections, a pool_size of 20 is pure risk, not capacity.
  • Set max_db_connections and max_user_connections. These cap total server connections per database and per user across all pools, turning unbounded multiplication into a bounded one. They are 0 (unlimited) by default.
  • Consolidate users. Every extra (database, user) pair is a pool. Fewer application roles means fewer pools and a smaller worst case.
  • Raise PostgreSQL max_connections only deliberately. Each connection costs backend memory, and a high limit invites the very pile-up PgBouncer exists to prevent. Raising the limit to make room for oversized pools is usually the wrong direction.
  • Treat reserve_pool_size as part of the budget, not as slack. If reserve usage is sustained, grow the base pool within budget or reduce demand; do not let the reserve mask chronic undersizing.
  • Never rely on pool_size = 0 on 1.24.0+. Unlimited pools make the entire audit meaningless.

If you are currently in the incident (PostgreSQL full, PgBouncer login failures), the fastest relief is freeing slots on the PostgreSQL side: terminate idle or runaway direct connections, and reduce demand by pausing non-critical database stanzas in PgBouncer (PAUSE dbname) rather than restarting anything. A PgBouncer restart during this failure makes it worse: every pool re-establishes connections at once, producing a login storm against a backend that is already full.

How Netdata helps

  • PostgreSQL connection utilization against max_connections as a first-class chart, so budget consumption is visible before the FATAL errors start, per backend.
  • PgBouncer pool state collection from the admin console (sv_active, sv_idle, sv_login, cl_waiting, maxwait per pool), letting you see when total server connections stall below pool_size while the backend is full: the signature of this failure.
  • Correlation between PgBouncer sv_login spikes and PostgreSQL connection saturation on one dashboard, which turns “is the pooler broken or is the database full?” into a glance instead of a two-terminal investigation.
  • Per-pool breakdowns so one hot (database, user) pair does not hide inside a healthy aggregate.
  • Alerting on approach, not just arrival: warn when PostgreSQL connection usage crosses a sustained percentage of max_connections, while there is still time to run the audit and shrink pools.