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.
Quick answer
- Measure connection setup time to your database from the app server.
- Set
CONN_MAX_AGEto a moderate value such as60seconds for WSGI deployments. - Enable
CONN_HEALTH_CHECKS = Trueso reused connections are verified before use. - Calculate maximum connections: processes × threads per process, for web and background workers, and keep it below the database limit.
- Keep
CONN_MAX_AGEshorter than any server-side or network idle timeout. - Do not use persistent connections with ASGI or async views; use a connection pool instead.
- Verify with
pg_stat_activityand 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.
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
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
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 unexpectedlyerrors 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:
| Deployment | Recommended starting point |
|---|---|
| WSGI, few workers, database on same network, low connection cost | CONN_MAX_AGE = 0 is fine; persistent connections add little |
| WSGI, managed database with TLS, moderate worker count | CONN_MAX_AGE = 60 with CONN_HEALTH_CHECKS = True |
WSGI, many servers or threads approaching max_connections | PgBouncer in transaction mode, or Django's pool with small per-process sizes |
| ASGI or async views | CONN_MAX_AGE = 0 plus Django's built-in pool or PgBouncer |
| Serverless or rapidly autoscaling containers | external pooler or provider pooling endpoint |
Revisit the choice when you change worker models, add servers, or move the database.
Troubleshooting persistent connection problems
| Symptom | Likely cause | Fix |
|---|---|---|
server closed the connection unexpectedly after idle periods | network device or database closed idle connections | enable CONN_HEALTH_CHECKS, lower CONN_MAX_AGE, add TCP keepalives |
FATAL: sorry, too many clients already after enabling persistence | every thread now holds a connection | reduce threads or add pooling |
terminating connection due to administrator command in logs | database restart or failover | expected; health checks reconnect on the next request |
Worker processes with many old connections in pg_stat_activity | Celery or management commands outside request cycle | close_old_connections() around tasks and loops |
Connections in idle in transaction state | code left a transaction open | fix 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 = Nonewith many workers and threads. - Enabling persistent connections under ASGI.
- Forgetting that Celery workers do not follow the request lifecycle.
- Keeping
CONN_MAX_AGElonger than a NAT or proxy idle timeout.
Next steps
- If connection counts are already a problem, see Fix PostgreSQL "Too Many Connections" in Django.
- Cap connections per process with Use Django's Built-In PostgreSQL Connection Pool.
- Multiplex across servers with Set Up PgBouncer for Django.
- Troubleshoot intermittent failures with Fix Database Connection Errors in Django Production.
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.