Configuring CONN_MAX_AGE for Django and PgBouncer

This guide is part of Django Database Connection Management. It addresses a specific production failure: Django’s CONN_MAX_AGE persistent-connection setting silently multiplies the backend count when PgBouncer sits in front of PostgreSQL, and the two layers fight over who owns connection lifetime. The visible symptom is a Postgres log line that appears even though PgBouncer reports plenty of free server connections:

FATAL: remaining connection slots are reserved for non-replication superuser connections

or, on the application side, intermittent psycopg2.OperationalError: server closed the connection unexpectedly after a deploy or a quiet period. Both come from the same root cause: every Gunicorn worker that Django holds open via CONN_MAX_AGE pins a client-side connection, and depending on PgBouncer’s pool_mode those pins either stack against the server pool or get severed underneath Django without it noticing. This guide explains the connection math, the interaction with PgBouncer’s pooling modes, and the exact settings that make persistent connections and a transaction pooler coexist.

Key operational takeaways:

  • Total client connections to PgBouncer ≈ web_processes × workers × DATABASES when CONN_MAX_AGE > 0. This is client-side, not server-side, but it caps PgBouncer’s max_client_conn.
  • CONN_MAX_AGE keeps a Django connection object warm; under PgBouncer transaction pooling that “warm” connection is not pinned to a server backend between transactions, so the saving is the TCP+TLS+auth handshake to PgBouncer only.
  • Always enable CONN_HEALTH_CHECKS = True (Django 4.1+) when CONN_MAX_AGE > 0 behind a pooler, or stale sockets surface as the first query of a request failing.
  • CONN_MAX_AGE must be shorter than PgBouncer’s server_idle_timeout and PostgreSQL’s idle_in_transaction_session_timeout reasoning windows, or you reuse a socket the proxy already recycled.
  • Never set CONN_MAX_AGE to a large value with prefork Celery workers; pair it with close_old_connections() as covered in Preventing Django Connection Leaks During Celery Tasks.

Rapid incident diagnosis

Start by separating client-side exhaustion (Django → PgBouncer) from server-side exhaustion (PgBouncer → PostgreSQL). They have different fixes and different log signatures.

Check PgBouncer first. Connect to the admin console and read the pool table:

-- psql against PgBouncer's admin interface (port 6432, database pgbouncer)
SHOW POOLS;
SHOW CLIENTS;

In SHOW POOLS, cl_active + cl_waiting is how many Django connections PgBouncer is holding; sv_active + sv_idle is how many real PostgreSQL backends are in use. If cl_waiting is non-zero and maxwait is climbing, clients are queuing for a server slot — that is default_pool_size being too small or pool_mode = session pinning backends. If cl_active is near max_client_conn and PgBouncer rejects new connections, Django is opening too many client connections, which is the CONN_MAX_AGE multiplication problem.

Observation Layer Likely cause
no more connections allowed (max_client_conn) in PgBouncer log Client (Django→PgBouncer) CONN_MAX_AGE > 0 × worker count exceeds max_client_conn
cl_waiting > 0, maxwait rising in SHOW POOLS Server (PgBouncer→PG) default_pool_size too small, or session pooling pinning
server closed the connection unexpectedly on first query of request Stale socket CONN_MAX_AGE longer than server_idle_timeout; no health check
remaining connection slots are reserved in PostgreSQL log Server (direct to PG) Session pooling, or apps bypassing PgBouncer

The signature that specifically indicts CONN_MAX_AGE is the combination of healthy sv_active (PostgreSQL is not saturated) with cl_active saturated at max_client_conn. Django is hoarding client connections it is not actively querying on.

Connection math: the per-worker formula

Django opens one connection object per database alias per process, and CONN_MAX_AGE > 0 keeps that object alive between requests. With a prefork web server the process count is the multiplier.

Define the peak client-connection demand against PgBouncer:

client_conns = web_processes
             × workers_per_process       (Gunicorn/uWSGI worker count)
             × db_aliases                 (entries in settings.DATABASES)
             × (1 if CONN_MAX_AGE > 0 else burst_factor)

Worked example. You run 4 application hosts, each Gunicorn with --workers 9 (the common 2 × vCPU + 1 for a 4-vCPU box), one database alias, and CONN_MAX_AGE = 60:

client_conns = 4 hosts × 9 workers × 1 alias × 1 = 36 persistent client connections

Those 36 connections live against PgBouncer continuously, even at low traffic, because each warm worker holds its connection for up to 60 seconds of idle. Set max_client_conn comfortably above this — at least 36 × 1.5 = 54 to absorb deploys where old and new workers overlap. Critically, these 36 client connections do not require 36 PostgreSQL backends under transaction pooling. PgBouncer multiplexes them onto default_pool_size server connections (commonly 20–25). That decoupling is the entire reason to put PgBouncer in front of a CONN_MAX_AGE-heavy Django fleet: persistent client connections, bounded server connections.

If you add async workers (gthread) the multiplier is still the process/worker count, not the thread count — each Django connection is thread-local, but threads within a worker reuse via the same lifecycle, and only as many as are concurrently in a request hold a distinct connection. Size max_client_conn for the worst case of threads_per_worker simultaneous in-flight requests if you use threaded workers.

How CONN_MAX_AGE interacts with PgBouncer pool_mode

The value of CONN_MAX_AGE means something different depending on PgBouncer’s pool_mode. Read PgBouncer Transaction vs Statement Pooling for the full mechanics; the summary for Django is:

pool_mode What CONN_MAX_AGE > 0 actually persists Server-backend impact
session A pinned PostgreSQL backend for the connection’s whole life Each warm worker pins one backend — defeats pooling; backends = client_conns
transaction Only the PgBouncer client socket; backend returns to pool after each transaction Backends bounded by default_pool_size; this is the intended setup
statement Same as transaction for socket; but multi-statement transactions break Use only for autocommit-only workloads; risky with the ORM

The trap: people set CONN_MAX_AGE = 600 expecting connection reuse, run PgBouncer in session mode “to be safe,” and end up with every worker pinning a backend for ten minutes. That is strictly worse than no pooler. Under transaction mode, CONN_MAX_AGE is purely a client-side optimization that saves the handshake to PgBouncer — keep it modest (30–60s) because the server-side win is already delivered by PgBouncer.

Exact remediation & configuration

Set Django to keep client connections warm but bounded, and let PgBouncer own the server backends.

# settings.py
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "appdb",
        "USER": "app",
        "PASSWORD": os.environ["DB_PASSWORD"],
        "HOST": "pgbouncer.internal",   # point at PgBouncer, NOT PostgreSQL directly
        "PORT": 6432,
        # Keep the client socket to PgBouncer warm for 60s of idle.
        "CONN_MAX_AGE": 60,
        # Django 4.1+: ping the connection before reuse, drop it if dead.
        "CONN_HEALTH_CHECKS": True,
        "OPTIONS": {
            # Required for transaction pooling: psycopg must not rely on
            # server-side prepared statements that outlive a transaction.
            # psycopg3:
            "prepare_threshold": None,
        },
    }
}

PgBouncer side, matched to the math above:

; pgbouncer.ini
[databases]
appdb = host=postgres-primary.internal port=5432 dbname=appdb

[pgbouncer]
pool_mode = transaction
; Above the worked-example peak (36) with deploy headroom.
max_client_conn = 200
; Server backends multiplexed across all clients. Keep well under
; PostgreSQL max_connections, leaving room for migrations and admin.
default_pool_size = 25
reserve_pool_size = 5
; Must be LONGER than Django CONN_MAX_AGE so Django never reuses a
; socket PgBouncer already closed server-side. 60s CONN_MAX_AGE -> 90s+ here.
server_idle_timeout = 120

The ordering constraint is non-negotiable: CONN_MAX_AGE (60) < server_idle_timeout (120). If server_idle_timeout were shorter, PgBouncer would recycle the server backend, but Django would still believe its 60-second connection is fine and send a query into a stale state, producing server closed the connection unexpectedly. CONN_HEALTH_CHECKS = True is the safety net that turns that race into a transparent reconnect instead of a 500.

Apply with zero downtime by rolling Gunicorn workers (HUP for graceful reload) after deploying the settings change; existing warm connections drain naturally as their CONN_MAX_AGE expires. Reload PgBouncer config with RELOAD; on the admin console — it does not drop existing client connections.

For Celery, do not rely on CONN_MAX_AGE for teardown. Long-lived workers must call close_old_connections() between tasks; see Preventing Django Connection Leaks During Celery Tasks for the signal handler.

Validation & verification

After deploying, confirm three things: client connections match the formula, server backends stay bounded, and no stale-socket errors appear.

Count what Django actually opened, from PostgreSQL’s view (these are PgBouncer’s server backends):

SELECT count(*), state
FROM pg_stat_activity
WHERE datname = 'appdb' AND usename = 'app'
GROUP BY state;

Under transaction pooling this count should hover near default_pool_size (25) regardless of how many Gunicorn workers are warm — that is the proof PgBouncer is multiplexing correctly. If it climbs toward your worker count instead, pool_mode is session.

Confirm the client side from PgBouncer:

SHOW POOLS;
-- cl_active should approximate web_processes × workers (your formula).
-- sv_active + sv_idle should stay <= default_pool_size.

Verify health checks are recycling stale sockets rather than erroring. Force a quiet period longer than server_idle_timeout, then issue a request and watch the application log: with CONN_HEALTH_CHECKS = True you see a transparent reconnect; without it you see OperationalError. Run a load test that idles between bursts to exercise exactly this path.

Frequently Asked Questions

Should I set CONN_MAX_AGE to None (unlimited) behind PgBouncer?
Avoid None for production fleets behind a transaction pooler. Unlimited persistence means Django never voluntarily recycles a client connection, so a socket can outlive PgBouncer restarts, network blips, and config reloads, leaning entirely on CONN_HEALTH_CHECKS to catch every staleness case. A bounded value like 30–60 seconds gives you periodic clean recycling with negligible handshake cost, because PgBouncer already absorbs the expensive PostgreSQL backend setup.
Does CONN_MAX_AGE reduce PostgreSQL connection count when using PgBouncer transaction pooling?
No — and that is the key misunderstanding. Under transaction pooling, CONN_MAX_AGE persists the client socket between Django and PgBouncer, not a PostgreSQL backend. The backend count is governed entirely by PgBouncer’s default_pool_size. CONN_MAX_AGE saves the handshake to PgBouncer; PgBouncer saves the handshake to PostgreSQL. They are separate optimizations.
Why do I get “server closed the connection unexpectedly” only after idle periods?
PgBouncer’s server_idle_timeout (or PostgreSQL’s tcp_keepalives) closed the underlying backend while Django still held a CONN_MAX_AGE connection object marked healthy. The next query hits a dead socket. Fix the ordering so CONN_MAX_AGE < server_idle_timeout, and enable CONN_HEALTH_CHECKS = True so Django pings before reuse and reconnects transparently.
Can I use server-side prepared statements with CONN_MAX_AGE and transaction pooling?
Not safely in transaction mode without configuration. A prepared statement created in one transaction may be executed on a different PostgreSQL backend after PgBouncer reassigns the pool, raising prepared statement "..." does not exist. Set prepare_threshold = None (psycopg3) or disable server-side prepares, or use PgBouncer 1.21+ prepared-statement support. See PgBouncer Transaction vs Statement Pooling.
How do I size max_client_conn for a Django fleet?
Use web_hosts × workers_per_host × db_aliases, then multiply by roughly 1.5 for deploy overlap where old and new workers briefly coexist. For 4 hosts × 9 workers × 1 alias that is 36 baseline, so max_client_conn of 100–200 is comfortable. This value is cheap on PgBouncer’s side because client connections do not consume PostgreSQL backends.