Configure Persistent Database Connections in Django

Use CONN_MAX_AGE and CONN_HEALTH_CHECKS correctly: when persistent connections help, connection math, idle timeouts, ASGI caveats, and measuring gains.

Problem statement

By default, Django opens a new database connection for each request and closes it at the end. For PostgreSQL, establishing a connection involves a TCP handshake, often a TLS handshake, authentication, and starting a new server process. That can add several milliseconds locally and tens of milliseconds to a managed database in another availability zone, on every request that touches the database.

Django's CONN_MAX_AGE setting keeps connections open between requests. It is a one-line change, but it interacts with worker counts, threads, database connection limits, server-side idle timeouts, network devices that drop idle connections, and ASGI. Configured carelessly, it trades connection latency for "too many connections" errors or intermittent server closed the connection unexpectedly failures.

This guide explains how persistent connections work in Django, how to choose values, and how to verify that they help.

Connection reuse per workerRequestneeds a connectionCONN_MAX_AGEreuse if healthyPostgreSQLone backend eachClosewhen stalePersistent connections trade a fixed connection count for lower per-request cost.

Quick answer

  1. Measure connection setup time to your database from the app server.
  2. Set CONN_MAX_AGE to a moderate value such as 60 seconds for WSGI deployments.
  3. Enable CONN_HEALTH_CHECKS = True so reused connections are verified before use.
  4. Calculate maximum connections: processes × threads per process, for web and background workers, and keep it below the database limit.
  5. Keep CONN_MAX_AGE shorter than any server-side or network idle timeout.
  6. Do not use persistent connections with ASGI or async views; use a connection pool instead.
  7. Verify with pg_stat_activity and latency measurements.

Step-by-step solution

1. Measure the cost you are trying to remove

From an application server:

python manage.py shell -c "
import time
from django.db import connections

conn = connections['default']
samples = []
for _ in range(10):
    conn.close()
    start = time.perf_counter()
    conn.ensure_connection()
    samples.append((time.perf_counter() - start) * 1000)
print('connect ms: min=%.1f avg=%.1f max=%.1f' % (min(samples), sum(samples) / len(samples), max(samples)))
conn.close()
"

Compare with typical request times. If connecting takes 2 ms and requests take 300 ms, persistent connections will not be noticeable. If connecting takes 25 ms (common with TLS to managed databases), they can remove a significant part of each request.

2. How CONN_MAX_AGE works

  • 0 (default): close the connection at the end of each request.
  • A positive integer: keep the connection for up to that many seconds, reusing it for subsequent requests in the same thread.
  • None: keep connections indefinitely.
one connection, many requestsRequest 1opens a connectionRequest 2reuses itRequest 3health check, reuseAge > 60 sclosed after the requestNext requestopens a fresh oneConnections are per worker thread and only checked between requestsdjango-deployment.com
With CONN_HEALTH_CHECKS, a dead reused connection is replaced before the query runs.

Django checks connection age at the start and end of each request (through the request_started and request_finished signals). Connections are per thread: each Gunicorn worker process, and each thread inside it, holds its own connection. Connections are not shared between processes.

3. Configure

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "HOST": os.environ["DB_HOST"],
        "NAME": os.environ["DB_NAME"],
        "USER": os.environ["DB_USER"],
        "PASSWORD": os.environ["DB_PASSWORD"],
        "CONN_MAX_AGE": 60,
        "CONN_HEALTH_CHECKS": True,
        "OPTIONS": {
            "connect_timeout": 5,
        },
    }
}

CONN_HEALTH_CHECKS (Django 4.1+) makes Django check a reused persistent connection at the start of a request before using it. If the database restarted or a network device dropped the connection, Django opens a new one instead of failing the first query of the request. The check runs once per request when a connection is reused, not before every query.

connect_timeout (a libpq option) bounds how long a new connection attempt can hang.

4. Do the connection math

With persistent connections, the number of open connections approaches your concurrency configuration and stays there:

web:    servers × gunicorn workers × threads
worker: celery hosts × concurrency
other:  beat, cron jobs, admin shells, monitoring
count every connection holderWeb: 2 servers × 5 workers10Celery: 2 × concurrency 48Cron, shells, admin tools~5PostgreSQL max_connections1000255075100connectionsThreads multiply it: each thread holds its own connectiondjango-deployment.com
Leave headroom for deploys, when old and new processes overlap.

Example: 2 servers × 4 workers × 4 threads = 32 web connections, plus 2 × 8 Celery processes = 16, plus a few others, roughly 55. On a database with max_connections = 100 and reserved slots, that is fine; scaling to 6 servers and more threads may not be.

When numbers get tight, reduce threads, add a pooler (PgBouncer), or use Django's built-in connection pool, which caps connections per process.

5. Respect idle timeouts along the path

Connections that sit idle between requests can be closed by:

  • PostgreSQL idle_session_timeout (off by default, sometimes set by providers)
  • managed database proxies and poolers with their own idle limits
  • cloud NAT gateways and load balancers that drop idle TCP flows (some after around 350 seconds)
  • firewalls with connection tracking timeouts
something on the path may close idle connectionsDjango CONN_MAX_AGEmust be the shortestPgBouncer or proxyserver_idle_timeoutFirewall or NATsilent drops after idle minutesPostgreSQLidle_session_timeoutIf anything below closes first, the next query failsdjango-deployment.com
Cloud NAT and load balancers often drop idle TCP connections without telling either end.

If any of these is shorter than CONN_MAX_AGE, the app eventually reuses a dead connection. CONN_HEALTH_CHECKS catches this at request start, but keeping CONN_MAX_AGE below the shortest idle timeout avoids the extra reconnection work. Values between 30 and 300 seconds are common.

TCP keepalives help long-lived connections survive network devices:

"OPTIONS": {
    "connect_timeout": 5,
    "keepalives": 1,
    "keepalives_idle": 60,
    "keepalives_interval": 10,
    "keepalives_count": 3,
},

These are libpq connection parameters, passed through by Django's PostgreSQL backend.

6. Background workers and scripts

Persistent connections are managed at request boundaries. Code outside requests (Celery tasks, management commands, long-running scripts) does not trigger those signals, so connections can live much longer than CONN_MAX_AGE, or break without being replaced.

For Celery, close stale connections around tasks:

from celery.signals import task_postrun, task_prerun
from django.db import close_old_connections


@task_prerun.connect
def _close_before(**kwargs):
    close_old_connections()


@task_postrun.connect
def _close_after(**kwargs):
    close_old_connections()

For long-running management commands that loop, call close_old_connections() between iterations.

7. ASGI and async views

Under ASGI, Django runs synchronous database code in threads managed by asgiref, and the request lifecycle differs from WSGI. Django's documentation advises disabling persistent connections in async deployments (CONN_MAX_AGE = 0) and using a connection pool instead, either Django's built-in pool with psycopg 3 or an external pooler. Leaving CONN_MAX_AGE above zero with ASGI can leak connections across threads.

8. Verify

On the database:

SELECT application_name, state, count(*),
       max(now() - backend_start) AS oldest_connection
FROM pg_stat_activity
WHERE usename = 'myapp'
GROUP BY application_name, state;

With persistent connections working, you see stable counts of idle connections whose ages cycle up to roughly CONN_MAX_AGE rather than constant churn.

On the application side, compare p50 latency for a simple database-backed endpoint before and after, under realistic traffic:

for i in $(seq 1 50); do
  curl -s -o /dev/null -w "%{time_total}\n" https://www.example.com/api/ping-db/
done | sort -n | awk '{a[NR]=$1} END {print "p50:", a[int(NR*0.5)], "p95:", a[int(NR*0.95)]}'

Verification check:

  • connection count stays below the database limit during peak and deploys
  • no server closed the connection unexpectedly errors after idle periods
  • latency for database-backed requests improves by roughly the measured connect time
  • Celery workers do not accumulate broken connections

Explanation

Opening a PostgreSQL connection is expensive relative to a simple query, so reusing connections saves time on every request. Django's implementation is intentionally simple: one connection per thread, kept for a maximum age and checked for health when reused. That simplicity means the right settings depend on your deployment's concurrency and network path. When concurrency grows beyond what the database can hold as individual connections, pooling takes over the job persistent connections cannot do.

Edge cases / notes

Transactions left open

A request that ends with an open transaction (for example due to a bug in manual transaction handling) returns a connection in a failed or in-transaction state. Django rolls back unusable connections, but code that manages transactions manually should always close them.

Database failovers

After a managed database failover, existing connections point at the old primary or are dropped. Health checks and short CONN_MAX_AGE values help clients reconnect quickly to the new primary through the same DNS name.

Testing

The test runner closes connections at the end of tests. Persistent connection settings rarely matter in tests but can affect tests that use threads.

Choosing between options

A quick decision guide for the database connection strategy:

DeploymentRecommended starting point
WSGI, few workers, database on same network, low connection costCONN_MAX_AGE = 0 is fine; persistent connections add little
WSGI, managed database with TLS, moderate worker countCONN_MAX_AGE = 60 with CONN_HEALTH_CHECKS = True
WSGI, many servers or threads approaching max_connectionsPgBouncer in transaction mode, or Django's pool with small per-process sizes
ASGI or async viewsCONN_MAX_AGE = 0 plus Django's built-in pool or PgBouncer
Serverless or rapidly autoscaling containersexternal pooler or provider pooling endpoint
which connection setup fitsBest forCONN_MAX_AGE = 0low traffic, simplestCONN_MAX_AGE = 60✓most WSGI deploymentspsycopg poolmany threads or ASGI, Django 5.1+PgBouncermany servers sharing one databaseChange one thing at a time and measure connection countsdjango-deployment.com
Do not combine a psycopg pool with a non-zero CONN_MAX_AGE.

Revisit the choice when you change worker models, add servers, or move the database.

Troubleshooting persistent connection problems

SymptomLikely causeFix
server closed the connection unexpectedly after idle periodsnetwork device or database closed idle connectionsenable CONN_HEALTH_CHECKS, lower CONN_MAX_AGE, add TCP keepalives
FATAL: sorry, too many clients already after enabling persistenceevery thread now holds a connectionreduce threads or add pooling
terminating connection due to administrator command in logsdatabase restart or failoverexpected; health checks reconnect on the next request
Worker processes with many old connections in pg_stat_activityCelery or management commands outside request cycleclose_old_connections() around tasks and loops
Connections in idle in transaction statecode left a transaction openfix transaction handling; set idle_in_transaction_session_timeout

A useful query for spotting stale sessions from one application:

SELECT pid, application_name, state,
       now() - backend_start AS connection_age,
       now() - state_change AS in_state_for
FROM pg_stat_activity
WHERE usename = 'myapp'
ORDER BY connection_age DESC
LIMIT 20;

Connections much older than CONN_MAX_AGE indicate processes that do not follow the request lifecycle, typically background workers or long-running commands.

Common mistakes

  • Setting CONN_MAX_AGE = None with many workers and threads.
  • Enabling persistent connections under ASGI.
  • Forgetting that Celery workers do not follow the request lifecycle.
  • Keeping CONN_MAX_AGE longer than a NAT or proxy idle timeout.

Next steps

FAQ

What is a good CONN_MAX_AGE value for Django?

For typical WSGI deployments, 60 seconds is a reasonable start. Keep it below network and database idle timeouts, and check that total connections stay within the database limit.

What does CONN_HEALTH_CHECKS do?

When a persistent connection is reused at the start of a request, Django first checks that it still works and reconnects if not, which avoids errors after database restarts or dropped idle connections.

Should I use CONN_MAX_AGE with ASGI?

No. Django recommends disabling persistent connections for async deployments and using a connection pool instead.

Do persistent connections reduce the number of database connections?

No. They usually increase the steady-state number of open connections, because every worker thread keeps one. They reduce connection setup time, not connection count.

Keep going