How PgBouncer Handles Thousands of Connections on One Core
In the last post I showed a PostgreSQL cluster falling over at 47 connections — every connection a forked OS process, every idle process holding 5–10 MB of memory, and a hard FATAL: sorry, too many clients already wall when the slots ran out.
PgBouncer is the standard fix, and the headline numbers sound almost fake: a single PgBouncer process, on a single CPU core, can hold thousands of client connections at a couple of kilobytes each, in front of a Postgres that only ever sees ten backends. This post is about how. Not "install PgBouncer and set pool_mode" — what the process is actually doing, what happens to your query on the way through, and how to watch all of it live from the admin console.
This is the first post in a PgBouncer deep-dive series. Everything here runs on a real environment you can reproduce: a CNPG PostgreSQL 18 cluster on Kubernetes with max_connections = 50, fronted by PgBouncer deployed via the CNPG Pooler CRD with default_pool_size = 10. The manifests are linked at the end.
One process, one thread, one loop
Postgres is process-per-connection: the postmaster forks a dedicated backend for every client, and that backend exists so the connection can run queries — a full query executor, transaction machinery, and local memory, per connection.
PgBouncer inverts that completely. It is one process running a single-threaded event loop. No fork per client, no thread per client. Every client connection is just a socket plus a small C struct — the PgBouncer docs put the overhead at roughly 2 kB per connection. The event loop waits on all of those sockets at once and reacts to whichever one has bytes ready.
It can get away with this because of what it doesn't do. PgBouncer never parses your SQL, never plans a query, never touches a table. A connection that would cost Postgres a process costs PgBouncer an entry in an event loop. That's a difference of about three orders of magnitude per idle connection — kilobytes versus megabytes — and it's the entire reason a pooler works.
The comparison is stark on a live cluster. With 55 clients held open through PgBouncer:
kubectl top pod -n pool-demo-v01
NAME CPU(cores) MEMORY(bytes)
pool-demo-58f9d7b6c4-w2xkp 1m 9Mi
postgres-1 8m 1543Mi
postgres-2 5m 1489Mi
postgres-transaction-1-6d8f9b7c4-k2m7x 2m 11Mi
postgres-transaction-2-6d8f9b7c4-p9q4r 1m 10Mi
postgres-transaction-3-6d8f9b7c4-z5t8w 1m 10Mi
Each PgBouncer pod is holding client connections in ~10 MiB total — about what Postgres spends on one or two idle backends.
The trick underneath: speaking fluent Postgres
PgBouncer works because it speaks the PostgreSQL wire protocol natively. Your application connects to it with the same driver, same connection string format, same TLS handshake it would use against Postgres. Nothing in your code changes except the hostname.
But "speaks the protocol" undersells what's clever here. PgBouncer understands the protocol just enough to pool, and no more:
- It handles the startup packet and authentication itself, so it knows which user wants which database.
- It watches for ReadyForQuery messages coming back from the server. That message carries a transaction status byte —
Ifor idle,Tfor in-transaction,Efor in a failed transaction. That one byte is how PgBouncer knows a transaction has ended and (in transaction mode) the server connection can be taken back. - Everything else — your query text, your result rows — is relayed as opaque bytes. PgBouncer moves them between sockets without interpreting them.
That last point is why PgBouncer adds so little latency and uses so little CPU. It's not a SQL-aware proxy; it's a protocol-aware socket switchboard.
You can verify how transparent it is. Connect psql to the pooler service and poke around:
psql "postgres://app:[email protected]:5432/app?sslmode=require"
app=> SELECT version();
PostgreSQL 18.0 ... -- the real backend answers; PgBouncer never sees this as anything but bytes
From the client's chair, it's indistinguishable from Postgres. The only tells are operational: SHOW commands against the admin console (more below), and the fact that in transaction mode, two consecutive queries might be answered by two different backend PIDs — try SELECT pg_backend_pid(); twice outside a transaction and watch it change.
Life of a query through PgBouncer
Here's the end-to-end path, because once you can picture it, every PgBouncer config parameter and every gotcha in this series has a place to hang.
1. Client login. Your app opens a TCP (or TLS) connection to PgBouncer and sends a startup packet: user, database. PgBouncer authenticates the client itself — in our CNPG setup, by running an auth_query against Postgres to fetch the password hash (that's the next post in this series). No server connection is consumed yet. The client now exists as a ~2 kB entry in the loop.
2. Pool assignment. PgBouncer files the client into a pool, keyed by the (database, user) pair. Every parameter you'll ever tune — default_pool_size, reserve_pool_size — applies per pool, not globally. This is one of the most misunderstood facts in PgBouncer sizing, and it gets a full post later in the series.
3. Waiting for a link. The client sends a query. Now — and only now — PgBouncer needs a real server connection. It looks in the pool for a free one. If one is idle, the client is linked to it instantly. If all default_pool_size connections are busy, the client goes into a queue (cl_waiting — the single most important number to monitor, also a later post). If the pool hasn't reached its size limit, PgBouncer opens a new server connection on demand.
4. Relay. While linked, PgBouncer shuttles bytes both ways between the client socket and the server socket. Your query and its results pass through untouched.
5. Unlink. When does the client give the server connection back? That's exactly what pool_mode means:
- session — at client disconnect. The link lives as long as the client does.
- transaction — at transaction end, detected via that ReadyForQuery status byte. The server connection returns to the pool and the next waiting client grabs it.
- statement — after every statement (rarely usable; it forbids multi-statement transactions).
Before a returned server connection is handed to a different client, PgBouncer runs server_reset_query (default DISCARD ALL) on it to clear session state. That reset — what it cleans, what it costs, and what leaks anyway — is its own post.
That's the whole machine. Multiplexing falls out of step 5: with transaction mode and mostly-short transactions, ten server connections can serve dozens of clients, because at any instant most clients are between transactions and hold nothing.
Watching it live: the admin console
PgBouncer exposes its internals through a fake database named pgbouncer. Connect to it like any other database and you get an admin console — not SQL against real tables, but a command interface that happens to speak the Postgres protocol. On a CNPG pooler pod, it listens on a Unix socket inside the container:
kubectl exec -n pool-demo-v01 postgres-transaction-1-6d8f9b7c4-k2m7x -c pgbouncer -- \
psql -h /controller/run -p 5432 -U pgbouncer pgbouncer -c "SHOW CLIENTS;"
With a storm of 55 clients running against the transaction pooler, SHOW CLIENTS gives you one row per client connection. The columns that matter:
type | user | database | state | addr | link | remote_pid
------+------+----------+---------+------------+----------------+-----------
C | app | app | active | 10.0.2.41 | 0x7f3a2c01e2c0 | 41527
C | app | app | waiting | 10.0.2.41 | | 0
C | app | app | waiting | 10.0.2.41 | | 0
...
- state —
activemeans this client is currently linked to a server connection;waitingmeans it has a query queued and is waiting for a free one. - link — the memory address of the server connection this client is linked to. Empty means no backend is being consumed by this client right now. In a healthy transaction-mode pooler, most clients show an empty link most of the time — that empty column is literally the money you're saving.
- remote_pid — the Postgres backend PID on the other side of the link. This is how you chase a specific client's query into
pg_stat_activity.
The mirror image, SHOW SERVERS, lists the real Postgres connections:
type | user | database | state | addr | link | remote_pid
------+------+----------+--------+-------------+----------------+-----------
S | app | app | active | 10.0.3.112 | 0x7f3a2c020180 | 41527
S | app | app | active | 10.0.3.112 | 0x7f3a2c021040 | 41530
S | app | app | idle | 10.0.3.112 | | 41533
...
Ten rows. Always ten (or fewer), no matter how many clients are storming — that's default_pool_size staring back at you. active servers are linked to a client mid-transaction; idle servers are in the pool waiting for work.
Run SHOW CLIENTS repeatedly while load is flowing and you can watch a single client's link column fill and empty as it's linked and unlinked between transactions — the core multiplexing move, visible in real time. And SHOW SOCKETS shows the union of both sides with buffer state attached, which is mostly useful when you're debugging a stuck connection and want to see whether bytes are sitting unflushed in a buffer.
One thing worth noticing: SHOW commands are not SQL. There's no WHERE, no ORDER BY — the admin console parses a tiny command language of its own. If you've ever wondered why you can't SELECT * FROM pg_stat_activity on the pooler: there is no database there. It's the event loop describing itself.
The honest limits
Single-process, single-threaded cuts both ways, and it's better to hear it here than discover it in an incident:
- One core is the ceiling. PgBouncer will never use more than one CPU. For most workloads the relay work is so cheap this doesn't matter, but heavy TLS termination at high connection-churn rates can saturate that core.
- The fix is horizontal. Modern PgBouncer supports
so_reuseport, letting multiple processes share one listening port. On Kubernetes you get the same effect more naturally: run multiple pooler pods behind one Service. That's why our CNPGPoolerrunsinstances: 3— three independent PgBouncer processes, one service name. - Horizontal scaling multiplies your pools. Three poolers ×
default_pool_size = 10can mean up to 30 backend connections, not 10. Against ourmax_connections = 50that's a real number. Every replica runs its own event loop and its own pools, and they don't coordinate. This trap gets a full post in the sizing installment.
Reproduce it
The entire environment is code:
- Manifests — CNPG
Cluster+PoolerCRDs iniqtoolkit-infra/k8s/pool-demo-v01/, synced by ArgoCD. No hand-writtenpgbouncer.ini, nouserlist.txt— the operator generates both (next post covers how). - Load — the
pool-demoGo app from the video series:pool-demo storm --count 55against the pooler DSN, then run theSHOWcommands above while it holds.
If you'd rather watch it than run it, the connection pooling video series demos this same cluster live.
What's next
PgBouncer holds thousands of cheap client sockets in one event loop, authenticates them itself, queues them per (database, user) pool, and links them to a small fixed set of real backends only for as long as the pool mode requires. Everything else in this series — auth, sizing, session-state gotchas, monitoring, zero-downtime ops — is a consequence of that one design.
Next post: authentication. Your app logs into PgBouncer, and PgBouncer logs into Postgres, and those are two different logins with two different credential stores. Confusing them is half of all PgBouncer support threads — and CNPG's auth_query + TLS setup makes the whole problem disappear, if you understand what it's doing on your behalf.
_This post is part of the PgBouncer deep-dive series — the written companion to the PostgreSQL Connection Pooling video series. For PostgreSQL internals in your inbox every other week, subscribe to the Vacuum and Analyze newsletter._
Vacuum & Analyze — bi-weekly PostgreSQL internals, no spam.