Skip to content

Connection pooling

A surprising amount of production trouble traces back to one fact: in PostgreSQL, every client connection is backed by its own operating-system process on the server. A few connections cost almost nothing. A few thousand cost a great deal of memory and scheduling overhead, and the server slows to a crawl long before it runs out of useful work to do.

Connection pooling fixes this by putting a small middleman between your applications and the database. The pooler keeps a modest number of real server connections open and hands them out to clients as needed, so the database only ever deals with a handful of connections no matter how many clients exist.

When a client connects, the PostgreSQL postmaster forks a dedicated backend process to serve it. That process holds memory for caches, sort buffers, and bookkeeping, and the operating system must schedule it alongside every other backend. Idle connections still occupy a process; a web app that opens a connection per request and leaves it idle between requests wastes that cost over and over.

This is why a default install caps max_connections at a low number such as 100. Raising it does not make the problem go away — it just lets you create more expensive processes.

flowchart LR
  Cli[Many app clients] -->|hundreds of connections| PG[(PostgreSQL)]
  PG --> B1[Backend process]
  PG --> B2[Backend process]
  PG --> B3[Backend process]
  PG --> B4[Backend process]
  PG --> B5[Backend process]
Without pooling, each client gets its own server process

A pooler such as PgBouncer sits between the clients and the server. Clients connect to the pooler, which is cheap to talk to, and the pooler maintains a small set of real connections to PostgreSQL. When a client needs the database, the pooler lends it a server connection, then takes it back so another client can reuse it.

flowchart LR
  Cli[Thousands of clients] -->|cheap client connections| Pool[PgBouncer pooler]
  Pool -->|few reused server connections| PG[(PostgreSQL)]
Many clients share a few server connections through a pooler

The result is dramatic: ten thousand application clients might be served by twenty real PostgreSQL connections, because no client holds a server connection while it is idle.

PgBouncer offers three modes that differ in how long a client keeps a borrowed server connection.

ModeServer connection is returned after…Notes
SessionThe client disconnectsSafest, but a connection is held for the whole session
TransactionEach transaction commits or rolls backThe common choice; great reuse with one caveat
StatementEach individual statementMost aggressive; forbids multi-statement transactions

Transaction mode is the popular default. A server connection is borrowed only for the duration of one transaction, so between transactions it is free for others. This gives excellent reuse for typical web traffic, where each request is a short transaction.

The catch is that anything tied to a session rather than a transaction can break, because the next transaction may land on a different server connection. Prepared statements, session-level SET commands, advisory locks, and LISTEN/NOTIFY all assume connection continuity that transaction mode does not provide.

PgBouncer is configured with a small INI file. The essentials are which databases it fronts, where it listens, and the pooling mode.

[databases]
shop = host=127.0.0.1 port=5432 dbname=shop
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 10000
default_pool_size = 20

Here max_client_conn is how many clients may connect to PgBouncer, while default_pool_size is how many real PostgreSQL connections it keeps per database — twenty server connections fronting up to ten thousand clients.

From your application’s point of view, the pooler looks just like PostgreSQL. You point your driver at the pooler’s host and port — 6432 above instead of 5432 — and nothing else changes.

Terminal window
# Connect to PgBouncer instead of PostgreSQL directly
psql "host=127.0.0.1 port=6432 dbname=shop user=app"

In pgAdmin: register a new server connection whose host and port point at PgBouncer (port 6432). pgAdmin treats it like any PostgreSQL server, which is a handy way to confirm the pooler is reachable.

  • Transaction mode is the usual recommendation because it gives the best connection reuse for short web requests. Reach for session mode only when you genuinely need session continuity.
  • In transaction mode, avoid features that span transactions on one connection — server-side prepared statements, session SET, advisory locks, and LISTEN/NOTIFY may misbehave. Many drivers offer a setting to disable server-side prepared statements for this reason.
  • A pooler does not replace the driver’s own connection pool; you can run both, but size them so the driver never asks for more than the pooler can supply.
  • Set default_pool_size based on your server’s capacity, not your client count. A small pool that is always busy beats a large pool that swamps the database.
Why are PostgreSQL connections considered heavy?
In PgBouncer transaction mode, when is a server connection returned to the pool?
Which feature is most likely to misbehave under transaction-mode pooling?