Sign in
← Data
11 min · medium · Lesson 3 of 5

Connection pooling (pgbouncer)

Why database connections are expensive, how a pool reuses them, and why pgbouncer sits in front of Postgres.

A connection to the database is expensive

In Postgres, each connection is served by its own server process. It costs memory and setup time. While there aren't many apps, this goes unnoticed. But when ten copies of the app from the load-balancing lesson are running, and each holds twenty connections, that adds up to two hundred processes. The database hits the max_connections limit (100 by default, usually) and starts refusing new connections.

The refusal doesn't look the way articles often describe it. There's no too many connections phrase in Postgres, there are two other ones. By default, the last three slots are reserved for superusers (superuser_reserved_connections = 3), so a regular app role is the first to see this error:

FATAL:  remaining connection slots are reserved for roles with the SUPERUSER attribute

When every slot is taken, Postgres responds like this:

FATAL:  sorry, too many clients already

The first line is worded as in Postgres 16. In Postgres 14 and 15 the ending differs: reserved for non-replication superuser connections. There's also a per-role limit (CONNECTION LIMIT in CREATE ROLE), which gives the error too many connections for role "shop".

What a pool does

A connection pool holds a small set of already-open connections to the database and hands them to the app for the duration of a request, then takes them back. The app doesn't open a connection for every request, it grabs a ready one from the pool.

app pool · 20 connections Postgres

A pool comes in two kinds. In-app (a pooling library inside the service itself) and external (a separate service in front of the database). You need an external one when there are many copies of the app: then one shared pool caps the total number of connections to the database, instead of each copy capping its own.

pgbouncer

pgbouncer is a lightweight external pool for Postgres. Apps connect not to the database directly but to pgbouncer (port 6432 by default), and it keeps a small set of connections to the actual database. To the app, pgbouncer looks like a regular Postgres: only the host and port in the connection string change.

app ×10 pgbouncer Postgres 200 clients holds a set 25 real

A hundred client connections can be served by two dozen real ones, because only part of the requests are actively running at any given moment.

A minimal pgbouncer.ini config looks like this:

[databases]
appdb = host=postgres port=5432 dbname=appdb

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
default_pool_size = 20
max_client_conn = 100

The [databases] section records where pgbouncer goes for the real database. max_client_conn limits how many clients pgbouncer will accept. default_pool_size sets how many connections to the database it opens for one "database + user" pair. Client passwords live in auth_file, as a line like "appuser" "password".

Pool modes

pool_mode sets the mode, and it decides when a server connection is returned to the pool.

Transaction pooling has a cost. A client's next transaction can land on a different server connection, so anything that lives in the session breaks: SET and RESET, LISTEN, PREPARE, temporary tables without ON COMMIT DROP, WITH HOLD cursors. pgbouncer 1.21 and newer can carry protocol-level prepared statements (the ones drivers send) across connections if max_prepared_statements is set. If the app depends on session state, it needs session pooling.

When there are more clients than connections

pgbouncer doesn't refuse a client when there's no free connection to the database. The client gets in line and waits for a connection to free up. It can wait up to query_wait_timeout (120 seconds by default), after which pgbouncer disconnects it. While the queue is short, this is normal. A long queue looks like a slow app with a calm database: the query runs fast in Postgres, and the time goes into waiting in pgbouncer.

You can see the queue in the admin console. It's a pseudo-database called pgbouncer, and you connect to it with regular psql under a user from admin_users:

psql -h 127.0.0.1 -p 6432 -U appuser pgbouncer

The SHOW POOLS; command shows a row for each "database + user" pair:

 database |  user   | cl_active | cl_waiting | ... | sv_active | sv_idle | ... | maxwait | ... |  pool_mode
----------+---------+-----------+------------+-----+-----------+---------+-----+---------+-----+-------------
 appdb    | appuser |        10 |         20 | ... |        10 |       0 | ... |       1 | ... | transaction

If cl_waiting and maxwait keep growing, the pool can't keep up. There are two causes: the database itself is overloaded, or the pool is too small for the number of concurrent requests. SHOW DATABASES; shows the settings for each database, including pool_size. After editing pgbouncer.ini, the settings are applied by running RELOAD; in the admin console or by restarting pgbouncer.

The pool size has an upper bound set by the database. The sum of the pools of all pgbouncers, plus any apps that connect to the database directly, must fit within max_connections minus the reserved slots, and within the role's CONNECTION LIMIT. If pgbouncer tries to open one connection too many, Postgres refuses it, and pgbouncer's log shows a line like server login failed: FATAL too many connections for role "...".

Where this shows up in production

The setup is simple: the app only knows pgbouncer's address, and pgbouncer sorts out the real connections. This takes connection pressure off the database, especially once the app scales horizontally and there are many copies of it.

A sign that it's time to add a pool: errors about exhausted connection slots, or the database eating memory with hundreds of connection processes while the actual load on queries is small.

Common mistakes

What you'll need for the tasks

In the next lesson we'll look at Redis: cache and shared state for scalable services.

Done reading?

Mark the lesson as read to move your track progress forward.

Go through it with an AI agent

The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.

Get access for $49

Tell the agent: use MCP izi, give me lesson data-basics-03-connection-pooling