postgres://internals

Connections & Pooling

// the problem

Open a connection to Postgres and it does something surprising: it forks a whole operating-system process — a dedicated backend — just for you. That process is powerful (it's where your queries run) but it isn't free, and there's a hard cap on how many can exist. A serverless app that opens a fresh connection per request will hit that wall fast. Understanding this — and the pooler that fixes it — is one of the most practical things you can know about running Postgres at scale.

Below, clients ask to run transactions against a server with a limited number of backends. Flip the pooler on and off and watch what happens to the overflow.

fig.01 · connections & pooling
clients
8
backends max
4
in use
0
waiting
0
rejected
0

server backends (processes)

·
·
·
·

clients

12345678

without a pooler, every client needs its own backend — extras are rejected

With the pooler off, every client needs its own backend; once they're exhausted, extra clients are simply rejected (FATAL: sorry, too many clients already). Turn the pooler on and those same clients queue for a small, shared set of backends — nobody is rejected, they just take turns.

One process per connection

Postgres's postmaster forks a backend process for each connection. That process holds its own memory — catalog caches, prepared statements, per-backend buffers — so even an idle connection costs on the order of a few megabytes. On top of that baseline, every sort or hash a query runs can allocate up to work_mem while it runs (and a complex query can run several at once). The total number of backends is capped by max_connections:

sql · live postgresrun me first
⌘/ctrl + enter

Because each backend can allocate work_mem for every sort or hash in a query, raising max_connections into the thousands is a memory time-bomb. Do the arithmetic — even one sort per connection at the cap:

sql · live postgresthe worst-case sort memory at max_connections
⌘/ctrl + enter

That's the ceiling if every connection runs one sort. A real query runs several sorts and hashes at once, so multiply again: 100 connections × 4 MB × 5 operations is 2 GB — from work_mem alone, on top of shared_buffers and each backend's own baseline. Push max_connections into the thousands and one traffic spike can OOM a box with plenty of idle CPU. The healthy number of active backends is small — a few per CPU core; everything past that just context-switches and contends.

// why it matters · idle connections aren't harmless

An idle connection still holds its process and memory. Worse, an idle connection in a transaction pins the cleanup horizon (from the VACUUM lesson) and blocks vacuuming database-wide. “Thousands of mostly-idle connections” is the classic way a busy app melts a database that has plenty of CPU to spare.

The fix: a connection pooler

A pooler sits between your app and Postgres and keeps a small set of real backends open, lending them out to many client connections. It solves two problems at once. The obvious one: many clients share a few backends, so you never hit max_connections. The subtler one: opening a backend isn't free — each new connection costs an OS fork, authentication, and warming that backend's catalog caches, roughly a millisecond or two plus a few MB every time. An app that connects, runs one query, and disconnects on every request (the classic serverless pattern) pays that toll constantly. A pooler keeps backends warm and reused, so clients skip the setup entirely.

The most impactful mode is transaction pooling (PgBouncer's transaction mode, and what Supabase's Supavisor does): a client is assigned a backend only for the duration of a transaction, then it returns to the pool. Since most connections are idle most of the time, a few dozen backends can serve thousands of clients.

  • Session pooling — a backend is tied to a client for its whole session (simple, but doesn't multiplex much).
  • Transaction pooling — a backend is borrowed per transaction (huge multiplexing; the default choice at scale).
  • Statement pooling — per statement (most aggressive, most restrictions).

// gotcha · transaction pooling breaks session state

Because a client doesn't keep the same backend between transactions, anything that lives in a sessionSET parameters, session-level advisory locks, LISTEN/NOTIFY, some prepared statements, WITH HOLD cursors — won't behave as expected under transaction pooling. Apps designed for a pooler keep their transactions self-contained and avoid relying on cross-transaction session state.

Who's connected? Reading pg_stat_activity

pg_stat_activity is your window into every backend, and its state column is the one you'll read most:

  • active — running a query right now.
  • idle — connected but doing nothing. Harmless beyond the memory it holds.
  • idle in transaction — inside a BEGIN, waiting on the application, not the database. This is the dangerous one: it pins the VACUUM cleanup horizon and can hold locks while doing no work (the VACUUM and Locks lessons).
  • idle in transaction (aborted) — the same, but the transaction already errored and is just sitting there until someone sends ROLLBACK.

Add wait_event_type / wait_event and you can see what an active backend is blocked on — a lock, an I/O, the client. The classic firefight is WHERE state = 'idle in transaction' ORDER BY xact_start to find the forgotten transaction throttling the whole database.

sql · live postgreseditable — run it
⌘/ctrl + enter

Your turn

// challengewrite it yourself

What is this server's hard limit on the number of connections?

⌘/ctrl + enter
// challengewrite it yourself

Count how many backends are currently connected to the server.

⌘/ctrl + enter

// what you now understand

  • 01Postgres forks one OS process (a backend) per connection; each holds real memory even when idle, capped by max_connections.
  • 02High max_connections is a memory time-bomb: the work_mem ceiling is max_connections × work_mem × sorts-per-query (100 × 4 MB × 5 ≈ 2 GB). Keep active backends to a few per CPU core; the rest just context-switch.
  • 03Idle connections waste memory; idle-in-transaction ones pin the VACUUM horizon and can hold locks while doing nothing — the classic bloat/blocking bomb.
  • 04A pooler (PgBouncer / Supavisor) solves two things: sharing a few backends under max_connections, AND skipping the per-connection fork + auth + cache-warmup cost — vital for serverless connect-per-request apps.
  • 05Transaction pooling borrows a backend only per transaction (max multiplexing) but breaks cross-transaction session state (SET, session advisory locks, LISTEN/NOTIFY).
  • 06pg_stat_activity state = active / idle / idle-in-transaction / (aborted); wait_event shows what an active backend is blocked on. Hunt idle-in-transaction by xact_start.

// self-test

A serverless app opens a new Postgres connection on every request and starts failing with 'too many clients already' under load. Best fix?

// self-test

Why keep the number of active backends close to a small multiple of the CPU core count?

// go deeper

nextPartitioning