Skip to content

CONCEPT Cited by 1 source

PgBouncer pool sizing formula

The formula

The canonical PgBouncer sizing invariant is a single inequality:

num_pools × default_pool_size  ≤  max_db_connections  ≤  max_connections − reserved_slack

Where:

  • num_pools = number of distinct (user, database) pairs connecting through PgBouncer. With N users and M databases where every user can connect to every database, num_pools = N × M; with 1:1 user↔database binding (single-tenant), num_pools = number_of_tenants.
  • default_pool_size = PgBouncer's per-(user, database) pool size.
  • max_db_connections = PgBouncer's hard cap per database across all pools.
  • max_connections = Postgres's global connection ceiling.
  • reserved_slack = direct-connection budget held back for admin, superuser, and emergency use (see concepts/reserved-admin-connection-budget).

(Source: sources/2026-04-21-planetscale-scaling-postgres-connections-with-pgbouncer.)

What each term constrains

  • Left-hand side (num_pools × default_pool_size): the theoretical maximum number of upstream Postgres connections PgBouncer could open if every pool filled to capacity simultaneously. Not usually reached, but must be bounded because any burst can reach it.
  • Middle (max_db_connections): the hard cap PgBouncer enforces — even if pool arithmetic would exceed it, PgBouncer will refuse to open more. This is the safety valve against pool-arithmetic drift.
  • Right-hand side (max_connections − reserved_slack): the memory-safe ceiling on the Postgres side. max_connections is the Postgres-global hard cap; reserved_slack is the operator's reserve for admin tasks that must bypass PgBouncer.

Inverted in single-tenant-per-database architectures

The typical multi-tenant intuition — "size default_pool_size for peak concurrent queries, then cap with max_db_connections"inverts when num_pools is large.

Dicken's M-1280 scenario (200 single-tenant databases × 200 roles, 1:1 mapping):

  • num_pools = 200.
  • default_pool_size = 10 (innocuous-looking) would yield 200 × 10 = 2,0005× above max_connections=400.
  • Must set default_pool_size = 2 to satisfy 200 × 2 = 400 ≤ max_connections.

In the multi-tenant case, max_user_connections is the load-bearing dial (preventing one user from monopolising). In the single-tenant-per-database case, default_pool_size itself is the load-bearing dial.

Dicken's three scenarios as formula instantiations

Scenario num_pools default_pool_size Product max_db_connections max_connections Slack
PS-80 1 × 3 = 3 30 90 40 50 10
M-2650 1 × 3 = 3 200 600 450 500 50
M-1280 200 × 1 = 200 2 400 2 400 0 (per-pool cap)

The product column (num_pools × default_pool_size) exceeds max_db_connections in all three scenarios — that's the point. The cap arithmetic protects against pool drift; the product is theoretical maximum, not expected operating point.

The reserve discipline

Dicken's explicit framing: "We should always keep a few available direct connections reserved for admin tasks and other emergency scenarios. We NEVER want PgBouncer to use all of the connections!" (sources/2026-04-21-planetscale-scaling-postgres-connections-with-pgbouncer).

In formula terms, reserved_slack ≥ 0 is necessary. PS-80 reserves 10 slots, M-2650 reserves 50, M-1280 reserves 0 at the PgBouncer-side cap but allows Postgres superuser_reserved_connections to carve out slots for the superuser specifically.

Why a formula helps

Three benefits over ad-hoc sizing:

  1. Detects pool-arithmetic drift: a new user / new database adds a pool; if num_pools × default_pool_size now exceeds max_db_connections, the formula fires immediately.
  2. Enforces the admin reserve invariant: if max_db_connections = max_connections, there's no room for direct callers; the formula surfaces this.
  3. Makes the trade-off explicit: raising default_pool_size requires either adding Postgres memory (raising max_connections) or reducing num_pools. The knobs aren't independent.

Seen in

Last updated · 470 distilled / 1,213 read