01
the error that makes people scale the wrong thing
On Postgres 16 and later, running out of connections looks like this for an ordinary application role:
If you've set reserved_connections, which Postgres 16 added, the message names the pg_use_reserved_connections role instead. Older guides quote the pre-16 wording about "non-replication superuser connections", so searching for the new text often turns up less than you'd expect.
The tempting reaction is to add app instances, because the site is slow and slow sounds like capacity. With direct connections that makes things worse. Every Gunicorn worker thread, Celery process and management command holds its own connection, so another pod adds connections without adding database capacity, and Postgres runs each connection as a separate backend process with its own memory. Raising max_connections to 500 just moves the wall and spends memory that the page cache would have used better. What most Django apps need is a small number of connections doing work and a queue in front of them, which is what a connection pooler provides.
This checklist goes in the order we'd work through it: choose the pooler, configure PgBouncer so Django doesn't trip over it, size the pool from the database side, and then read the numbers that tell you whether the fix worked. Everything below was run against Postgres 18, PgBouncer 1.26 and Django 6.1 with psycopg 3.3.
02
choose the pooler before touching the worker count
There are three realistic options, and the right one depends on how many processes talk to the database.
The first is Django's own pool. Since Django 5.1, the PostgreSQL backend can keep a psycopg 3 connection pool per process, enabled through OPTIONS. It needs the psycopg[pool] extra, and it replaces persistent connections, so leaving CONN_MAX_AGE at a non-zero value raises ImproperlyConfigured: Pooling doesn't support persistent connections.
This is a pool per process, not per deployment. It helps a threaded or ASGI process reuse connections across requests, and Django's docs recommend the backend's pooling over persistent connections under ASGI. It doesn't cap the total: 12 Gunicorn processes with max_size: 4 can still open 48 connections, and autoscaling multiplies that. For a single app tier with a fixed, modest number of processes, it's enough and you don't need another moving part.
The second is PgBouncer, a separate process that accepts thousands of cheap client connections and multiplexes them onto a fixed number of real server connections. It's the answer once several things connect (web pods, Celery, cron jobs) or once the process count changes with load, because the cap on server connections lives in one place regardless of how many clients there are.
The third is a managed pooler from your provider, such as AWS RDS Proxy. It does the same job without you running anything, at the cost of less control over pool modes and settings. If your provider offers one and its defaults fit, it's a reasonable choice. The rest of this post assumes PgBouncer, because its settings are the ones that interact with Django.
03
pgbouncer in transaction mode, and what that breaks
PgBouncer has three pool modes. Session mode assigns a server connection for the whole client session, which saves little. Statement mode returns the connection after every statement, which rules out multi-statement transactions. Transaction mode assigns a server connection for the duration of a transaction and returns it at commit or rollback, and that's the one that turns 200 clients into 10 server connections.
A few of these lines are easy to misread. default_pool_size is per user and database pair, so three roles connecting to app can open three pools of 10, and max_db_connections is the setting that caps the database as a whole. max_client_conn defaults to 100, which is too low for a fleet of web pods but costs almost nothing to raise. The app_session entry points at the same database in session mode for the few things that need a sticky connection, which we'll come back to. The auth_file holds SCRAM secrets, and you can copy them from pg_authid.rolpassword so PgBouncer never needs the plain-text password.
Old configs often carry server_reset_query = DISCARD ALL alongside transaction mode, and it does nothing there. The PgBouncer docs say the reset query isn't used in transaction pooling, because clients aren't supposed to leave session state behind in the first place, unless you force it with server_reset_query_always. The line isn't harmful, but it suggests a safety net that doesn't exist.
The real constraint is session state. PgBouncer's feature map lists what never works in transaction mode: SET and RESET at session level, LISTEN, WITH HOLD cursors, SQL-level PREPARE, temp tables that outlive a transaction, and session-level advisory locks. SET LOCAL and transaction-scoped advisory locks are fine, because they end with the transaction. Anything that sets search_path per request or holds a pg_advisory_lock across requests needs rewriting before the switch.
Prepared statements used to be on that list, and a lot of advice still says to avoid them. PgBouncer 1.21 (October 2023) added support for protocol-level prepared statements in transaction mode, and 1.24 turned it on by default with max_prepared_statements = 200. Django with psycopg 3.1.8 or later uses client-side parameter binding by default, so it doesn't prepare anything unless you opt into "server_side_binding": True. If you do, the PgBouncer setting matters: 20 threads running the same query through Django with server-side binding worked cleanly against the config above, and failed with prepared statement "_pg3_0" does not exist and already exists errors once max_prepared_statements was set to 0.
On the Django side, two settings matter. DISABLE_SERVER_SIDE_CURSORS = True is required, because Django creates server-side cursors for .iterator() and they live on one server connection, while the next transaction may get another. The Django docs suggest two ways to keep them for large exports: wrap the iteration in atomic(), or point a second database alias at Postgres directly or at a session-mode pool like app_session. The other setting is CONN_MAX_AGE, and here there's a real trade-off. With 0, Django opens a new connection to PgBouncer for every request, which is cheap but not free. A value like 60 with CONN_HEALTH_CHECKS = True keeps the client connection to PgBouncer open between requests, and since PgBouncer still hands out server connections per transaction, that doesn't hold anything on the Postgres side.
Migrations should go through the session-mode entry or straight to Postgres. A migration that runs a plain SET lock_timeout relies on session state, and when a migration is holding locks you don't want pooler behaviour as a second variable. A separate alias or a different DATABASE_URL for the migrate job is enough.
04
size the pool from the database side
Start with what Postgres can give. The defaults are max_connections = 100 with superuser_reserved_connections = 3. Take off what bypasses the pooler, such as the migration job, an admin's psql, replication and monitoring agents, and what's left is the budget for max_db_connections. On a small instance that budget is usually far more than the number of queries the CPU can run usefully at once, and the pool should be sized to the work, not to the budget.
The work is easy to estimate with Little's law: the number of busy server connections equals throughput multiplied by the average transaction time. At 400 transactions per second with an average transaction of 25 ms, 10 connections are busy on average. A pool of 15 to 20 covers bursts. A pool of 80 doesn't make anything faster, it only lets more queries compete for the same cores and disks.
That relationship is easy to see with pgbench. Running 200 clients through the PgBouncer config above, each executing select pg_sleep(0.05), against a Postgres limited to max_connections = 20 gives this:
Ten server connections at 50 ms per transaction can do at most 200 transactions per second, and that's what came out, while the other 190 clients waited in PgBouncer. The same run pointed straight at Postgres failed at connection time with the reserved-slots error from the top of this post. The latency is the honest part: with a pool that small, each client waited about a second. The pool turns connection failures into queueing, and then it's your call whether the queue is acceptable or the database needs to do more per second.
On the application side, count the slots that can hold a transaction at the same time. Three web pods with 4 Gunicorn workers and 2 threads each give 3 x 4 x 2 = 24, and two Celery pods with concurrency 6 give 2 x 6 = 12, so 36 clients can want a connection at once. Not all of them run queries at the same moment, which is why a pool of 15 to 20 usually serves 36 clients fine. If Celery runs heavy batch jobs, give it its own database role in PgBouncer so it gets its own pool and can't starve web requests. max_user_connections caps that role across databases.
05
read the pool, not the connection graph
Once PgBouncer is in front, the Postgres connection count flattens and stops telling you anything. The PgBouncer admin console is where the queue is visible. Connect to the special pgbouncer database as an admin or stats user and run SHOW POOLS;. This is what it looked like in the middle of the pgbench run, trimmed to the columns that matter:
cl_waiting is the number of clients waiting for a server connection, and maxwait is how long, in seconds, the oldest one has waited. Occasional waiting during peaks is the pool doing its job. Waiting that's constant at normal load, while Postgres still has CPU and I/O to spare, means the pool is too small, so raise default_pool_size a step at a time. If sv_active is pinned at the pool size and Postgres CPU is pinned too, a bigger pool won't help, because the database itself is the bottleneck and the fix is in the queries. SHOW STATS; gives average transaction and query times per database, which are the inputs for the Little's law estimate above.
On Postgres, pg_stat_statements ranks queries by the time they consume, which is usually where the real fix is. It needs shared_preload_libraries = 'pg_stat_statements' and CREATE EXTENSION pg_stat_statements once:
Setting application_name per process type (web, celery, migrate) pays off here too. PgBouncer tracks it and sets it on the server connection for whichever client is using it, so grouping pg_stat_activity by it shows which part of the system holds the active connections:
Idle server connections show an empty name, because no client owns them at that moment. A long transaction that renders a CSV or calls an external API while holding a connection shows up here as an active web row that never goes away, and that's a code fix: move the slow part outside the transaction or into a background job.
06
when adding workers is the right call
A pooler fixes connection exhaustion, which is the common case, but it doesn't make every slowdown a database problem. If cl_waiting stays at zero, Postgres has plenty of headroom, and the app pods are pinned on CPU, then the app tier really is the bottleneck, and more workers or pods are the right answer. With PgBouncer in place, adding them no longer threatens the database, because the extra clients queue in the pooler instead of opening backends.
The order is what matters. Put a pooler in so the number of app processes and the number of database connections are no longer the same number, size it from the database's real throughput, and only then scale whatever the metrics say is saturated.
