The connection lifecycle, and `CONN_MAX_AGE`
coreadvancedBy default Django opens a database connection when a request first needs one and closes it when the response is finished. That is `CONN_MAX_AGE = 0`, and it means every request pays to establish a connection — a TCP handshake, authentication, and on PostgreSQL a new backend process. Setting `CONN_MAX_AGE` to a positive number of seconds keeps the connection open for reuse across requests; `None` keeps it forever. The catch is that a reused connection can have been closed at the other end while it sat idle, which is what `CONN_HEALTH_CHECKS = True` exists to handle.
Think of it as
A connection is not a lightweight handle — on PostgreSQL it is a server-side process with its own memory, so opening one is expensive and holding one has an ongoing cost. Those two facts pull in opposite directions, and `CONN_MAX_AGE` is where you choose between them. At `0` you pay setup on every request and hold nothing between requests, which is wasteful under load but very well behaved: the number of connections tracks the number of *in-flight* requests. At a positive value you pay setup rarely, but the number of connections now tracks the number of *worker processes*, whether or not they are doing anything, because each one keeps its connection parked. That change of denominator is the thing to internalise — it is why turning on persistent connections can push a system straight into connection exhaustion without any change in traffic. The second thing is that a persistent connection is a stateful object that outlives your control of it. The database may close it for being idle, a failover may replace the server underneath it, and a network device may drop the session silently. Django cannot know without asking, which is what the health check does — once per request, only when the database is actually used. Without it, the first query after such an event raises an error that looks random and clusters right after a database restart. Finally, `CONN_MAX_AGE` is per process, and the age is only checked between requests: a connection is never closed mid-request for being too old.
What we're doing: Turn on persistent connections without walking into exhaustion — by doing the arithmetic first.
- 1–9
- The arithmetic belongs in the settings file, not in someone's head. Every process that imports Django holds its own connection, and Celery workers are usually the group people forget to count.
- 21
- Sixty seconds is long enough to cover the gaps between a busy worker's requests and short enough that a rolling restart clears everything within a minute.
- 27
- Never enable persistence without this. The failure it prevents appears as a burst of errors right after a database restart, which is exactly when you can least afford extra noise.
- 30–32
- `statement_timeout` is the backstop for the whole scheme: it caps how long any single statement can occupy a connection, so a runaway query cannot quietly consume one of your 52.
Why this works: Persistent connections are a trade of setup cost against held connections, and the trade is only safe once the held number is known — which is why the count comes before the setting.
Setting `CONN_MAX_AGE` without `CONN_HEALTH_CHECKS`
Wrong
Better
What you see: Every database maintenance window, failover or idle-timeout produces a burst of `OperationalError: server closed the connection unexpectedly` — from workers that were doing nothing at the time, which makes the errors look unrelated to the event.
Why: A parked connection is a live socket that the other end can close without telling you: an idle timeout on the server, a failover to a new primary, or a firewall dropping an idle session. Django hands the connection back out and the first query on it fails. The health check exists exactly for this — the documentation describes it as improving "the robustness of connection reuse and prevent[ing] errors when a connection has been closed by the database server" — and it runs once per request, only when the database is used, so the cost is negligible. Preferring a bounded `CONN_MAX_AGE` over `None` also helps: connections recycle on their own, so a bad one cannot live forever.
- req 1 · 0 ms: CONN_MAX_AGE = 0: connect — TCP handshake, auth, a new PostgreSQL backend process
- req 1 · end: close — the connection is discarded — nothing is kept
- req 2: connect again, close again — the same setup cost, paid per request forever
- req 1 · 0 ms: CONN_MAX_AGE = 60: connect once — the same setup, but only this time
- req 2 · req 3: reuse — no setup at all — and the connection stays parked between requests, even while idle
- idle > 60 s: closed at the *start* of the next request — age is checked between requests; a long request is never cut off mid-flight
- after a DB restart: the parked connection is dead — CONN_HEALTH_CHECKS notices and reconnects; without it, the next query errors
What each setting actually changes
Together
Remember: `CONN_MAX_AGE = 0` is the default and means a connection per request — expensive but self-limiting, because the count tracks in-flight requests. A positive value changes the denominator to worker *processes*, which is a larger and constant number, so do the multiplication across web, Celery, beat and ad-hoc processes before enabling it. Always pair persistence with `CONN_HEALTH_CHECKS = True`, since a parked connection can be closed at the other end by an idle timeout or a failover, and prefer a bounded age over `None` so connections recycle on their own.
See also: worker multiplication and connection exhaustion · pooling pgbouncer and long running transactions · infra settings

