Database Connection Pooling in Django: What Actually Happens Under Load


Why your API starts throwing connection errors at 200 req/s even though Postgres is barely breaking a sweat

A few years back I got paged for an API that was returning intermittent 500s under load. CPU on the Postgres box was at 20%. Memory was fine. The app servers were fine. The errors were all FATAL: sorry, too many clients already.

The setup

Django, gunicorn, 8 workers per box, 6 boxes. Each worker opened its own connection to Postgres on first query and kept it open (CONN_MAX_AGE set to a few minutes). Do the math: 8 workers × 6 boxes = 48 persistent connections, plus Celery workers, plus a couple of admin scripts someone left running. Postgres’s default max_connections is 100. It doesn’t take much traffic to blow past that, especially with autoscaling spinning up more app boxes during a spike — which is exactly when you need the database the least stressed.

The subtle part: Postgres connections are not cheap. Each one forks a backend process and reserves memory for it, whether or not it’s doing any work. Idle connections still cost you. So “just raise max_connections” works until it doesn’t — you start fighting for memory and shared buffers do worse under memory pressure, and now you’ve traded a config error for a resource contention problem.

What fixed it

PgBouncer, running in transaction pooling mode, sitting between the app and Postgres. Instead of each Django worker holding a Postgres connection, workers connect to PgBouncer (cheap, PgBouncer connections are lightweight), and PgBouncer multiplexes a much smaller pool of real Postgres connections across them. A single PgBouncer instance can hold thousands of client connections while only ever making 20-30 to Postgres.

Two things trip people up here:

Transaction mode breaks anything session-scoped. SET search_path, prepared statements, session-level advisory locks — all leak across requests in transaction pooling mode because the underlying Postgres connection gets handed to a different client between transactions. Django doesn’t rely on much of this by default, but if you’re using SELECT ... FOR UPDATE sessions or raw session variables anywhere, audit it before you flip the switch.

CONN_MAX_AGE still matters, just differently. With PgBouncer in front, you generally want Django’s own connections short-lived (CONN_MAX_AGE = 0) so the app isn’t also trying to persist connections on top of PgBouncer’s pool — you’d otherwise be pooling a pool for no benefit.

The actual numbers

After the change: peak real Postgres connections dropped from ~140 (already past the old max_connections ceiling, hence the errors) to a steady 25. p99 latency on the hot endpoints improved too, which surprised me — turns out connection setup/teardown overhead was showing up in the tail even before we hit the hard limit.

If you’re running Django (or any framework with a naive per-worker connection model) against Postgres at any real scale, and you haven’t put a pooler in front of it, that’s usually the first lever worth pulling — before reaching for read replicas or bigger instances.