Filter concepts by levelShowing all levels.

Django · Database Connection Management

Database Connection Management

A fixed budget spent by processes rather than by traffic — and the ceiling refuses instead of degrading

  • CONN_MAX_AGE changes the denominator0 tracks in-flight requests; anything higher tracks worker processes
  • Never enable persistence without CONN_HEALTH_CHECKSa parked connection can be closed by an idle timeout or a failover
  • Connections are a product, not a sumhosts × processes × concurrency, plus Celery, cron and shells
  • statement_timeout caps how long a slot is heldwithout it, one bad plan consumes the pool one query at a time
  • Only a SHARED pooler fixes a fleet-wide countDjango 5.1's "pool" option is per process — min_size can raise the total

1.The connection lifecycle, and `CONN_MAX_AGE`

By 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.

Key features

  • `CONN_MAX_AGE` default is `0` — "preserving the historical behavior of closing the database connection at the end of each request"
  • A positive value keeps the connection for that many seconds; `None` means "unlimited persistent connections"
  • The age is only evaluated between requests, so a long request never has its connection closed underneath it
  • `CONN_HEALTH_CHECKS = True` prevents errors "when a connection has been closed by the database server", checking once per request and only if the database is accessed
  • With persistence on, connection count tracks worker *processes*, not concurrent requests — a different and much larger number
  • It is per process: 8 workers with `CONN_MAX_AGE=60` hold 8 connections even while idle

What each setting actually changes

What each setting actually changes
SettingConnections heldCost per requestWatch out for
`CONN_MAX_AGE = 0` (default)one per in-flight requesta full connection setupsetup cost dominates short requests
`CONN_MAX_AGE = 60`one per worker processnearly noneidle workers still hold connections
`CONN_MAX_AGE = None`one per worker, forevernearly nonestale connections after a restart or failover
`CONN_HEALTH_CHECKS = True`unchangedone cheap checknothing — pair it with any persistence
One worker process, three requests — with and without persistence
  1. req 1 · 0 ms

    CONN_MAX_AGE = 0: connect

    TCP handshake, auth, a new PostgreSQL backend process

  2. req 1 · end

    close

    the connection is discarded — nothing is kept

  3. req 2

    connect again, close again

    the same setup cost, paid per request forever

  4. req 1 · 0 ms

    CONN_MAX_AGE = 60: connect once

    the same setup, but only this time

  5. req 2 · req 3

    reuse — no setup at all

    and the connection stays parked between requests, even while idle

  6. 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

  7. after a DB restart

    the parked connection is dead

    CONN_HEALTH_CHECKS notices and reconnects; without it, the next query errors

  1. req 1 · 0 ms: CONN_MAX_AGE = 0: connect — TCP handshake, auth, a new PostgreSQL backend process
  2. req 1 · end: close — the connection is discarded — nothing is kept
  3. req 2: connect again, close again — the same setup cost, paid per request forever
  4. req 1 · 0 ms: CONN_MAX_AGE = 60: connect once — the same setup, but only this time
  5. req 2 · req 3: reuse — no setup at all — and the connection stays parked between requests, even while idle
  6. 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
  7. after a DB restart: the parked connection is dead — CONN_HEALTH_CHECKS notices and reconnects; without it, the next query errors

Command

python
DATABASES["default"]["CONN_MAX_AGE"] = 60        # seconds; 0 = per request, None = forever
DATABASES["default"]["CONN_HEALTH_CHECKS"] = True

`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.

2.Worker multiplication and connection exhaustion

The section's own question is the whole lesson: if you increase web workers from 4 to 40, what happens to database connections? With persistent connections, they go from 4 to 40 — per host. Connections are a product, not a sum: hosts × processes × (threads, if each can query concurrently), plus every Celery worker, every scheduled job, and every shell someone left open. PostgreSQL enforces a hard `max_connections`, and past it new connections are refused outright with `FATAL: sorry, too many clients already` — so the failure is an error, not a slowdown.

Key features

  • Connections = hosts × processes × concurrent-queries-per-process — a product, and every term is easy to raise by accident
  • PostgreSQL refuses past `max_connections`: `FATAL: sorry, too many clients already` — an error, not degradation
  • Some of `max_connections` is reserved (`superuser_reserved_connections`), so the usable number is lower than the setting
  • Celery workers, beat, cron jobs, migrations and shells all draw from the same budget as the web tier
  • `statement_timeout` caps how long one query can hold a connection; without it a single query can hold one indefinitely
  • An idle-in-transaction connection is worse than a busy one — it holds locks too; cap it with `idle_in_transaction_session_timeout`

Every term in the product, and what raises it

Every term in the product, and what raises it
TermTypical sourceRaised by
hostsweb tier replicasautoscaling — often automatically
processes per hostgunicorn/uvicorn workers"we scaled up to fix latency"
queries in flight per processthreads, or async concurrencyswitching to a threaded or async worker class
background tierCelery workers × concurrencyadding a queue or raising `--concurrency`
ad-hocshells, migrations, analytics, admin toolsa person, at the worst moment
The senior question, drawn: workers 8 → 40, against a hard ceiling

Connections are a product of processes, not of traffic — so a five-fold worker increase is a five-fold connection increase, whether or not anyone is using the site.

  • Two horizontal bars compare connection usage against a vertical dashed line marking max_connections = 100.
  • The "before" bar is short and green: 3 web hosts x 8 workers = 24, plus 2 Celery hosts x 12 workers = 24, totalling 48 connections — comfortably inside the ceiling.
  • The "after" bar is long and red: raising web workers from 8 to 40 gives 3 x 40 = 120, plus the same 24 from Celery, totalling 144.
  • The red bar crosses the ceiling line, and the point where it crosses is labelled with the error PostgreSQL raises: FATAL: sorry, too many clients already.
  • The Celery contribution is unchanged in both bars, which is the point — the tier nobody edited is what pushes the total over.

Command

text
connections = hosts x processes x concurrent_queries_per_process
              + celery_hosts x celery_concurrency
              + headroom for migrations, shells and autoscaling

Connections are a product — hosts × processes × concurrent queries — plus every Celery worker, scheduled job and shell, and the answer to "workers 4 to 40" is "connections 4 to 40, per host". Write the budget down, leave headroom for deploys and incident shells, and remember that the tier nobody edited still spends from it. Then bound how long a slot can be held: `statement_timeout` for runaway queries, `idle_in_transaction_session_timeout` for transactions left open, and `lock_timeout` so waiting for a lock fails fast instead of consuming a connection indefinitely.

3.Pooling, PgBouncer, and long-running transactions

A pool lets many application processes share a smaller number of real database connections. Django 5.1 added a built-in one for PostgreSQL — `"pool": True` in `OPTIONS`, which needs psycopg 3 and is ignored with psycopg2 — and that pool is per process. A separate pooler such as PgBouncer sits outside your application and is shared by every process, which is what actually solves the multiplication problem. Its most useful mode, transaction pooling, hands a connection back after each transaction, and that changes the rules: session state does not persist, and server-side cursors must be turned off.

Key features

  • Django 5.1 added `"pool": True` (or a dict of `ConnectionPool` kwargs) in `OPTIONS` — it "requires `psycopg[pool]` or psycopg-pool to be installed and is ignored with psycopg2"
  • The built-in pool is per process, so it amortises setup but does not reduce the fleet-wide connection count
  • PgBouncer is shared across processes — that is the one that decouples worker count from connection count
  • Transaction pooling requires `DISABLE_SERVER_SIDE_CURSORS: True`, because a cursor "is only accessible in the connection in which [it] was created"
  • `"pool"` and `CONN_MAX_AGE` are alternatives, not partners — a pool manages the lifetime itself
  • Under transaction pooling a long transaction holds a shared real connection, not merely a private one

Three ways to manage connections, and what each one actually fixes

Three ways to manage connections, and what each one actually fixes
ApproachScopeFixesCosts
`CONN_MAX_AGE`per processsetup cost per requestone held connection per process
`"pool": True` (Django 5.1+)per processsetup cost, with a managed lifetimestill per process; psycopg 3 only
PgBouncer, session modesharedsetup cost across the fleetlittle reduction in connection count
PgBouncer, transaction modeshared**the multiplication problem**no session state, no server-side cursors
Where each layer sits, and which one your worker count reaches

120 application processes

web workers + Celery workers, each importing Django

Per-process pool — `"pool": True` or `CONN_MAX_AGE`

amortises setup inside one process; 120 processes still mean 120 pools

PgBouncer — shared, transaction pooling

the only layer that makes 120 processes look like 20 connections

PostgreSQL — max_connections = 100

a hard ceiling that refuses rather than degrades

The constraint transaction pooling adds

no session state across transactions → DISABLE_SERVER_SIDE_CURSORS, so `.iterator()` streams client-side

  1. 120 application processes — web workers + Celery workers, each importing Django
  2. Per-process pool — `"pool": True` or `CONN_MAX_AGE` — amortises setup inside one process; 120 processes still mean 120 pools
  3. PgBouncer — shared, transaction pooling — the only layer that makes 120 processes look like 20 connections
  4. PostgreSQL — max_connections = 100 — a hard ceiling that refuses rather than degrades
  5. The constraint transaction pooling adds — no session state across transactions → DISABLE_SERVER_SIDE_CURSORS, so `.iterator()` streams client-side

Command

python
# behind PgBouncer in transaction pooling mode:
"DISABLE_SERVER_SIDE_CURSORS": True,
"CONN_MAX_AGE": 0,          # the pooler owns connection reuse now

Two different things are called pooling. Django 5.1's `"pool"` option is per process, needs psycopg 3, and amortises setup — it cannot reduce a fleet-wide total, and `min_size` can raise it. An external pooler such as PgBouncer is shared, and transaction pooling is the mode that decouples worker count from connection count; the price is that nothing may live on a connection across transactions, so `DISABLE_SERVER_SIDE_CURSORS` becomes mandatory and `CONN_MAX_AGE` becomes redundant. Either way, keep transactions short and free of network calls — a pooler cannot recycle a connection a transaction will not release.

Remember

  • Workers 4 → 40 means connections 4 → 40, per host, and the Celery tier nobody edited still spends from the same budget.
  • Past `max_connections` PostgreSQL refuses outright — the failure is an error, not a slowdown.
  • Behind PgBouncer in transaction mode, set `DISABLE_SERVER_SIDE_CURSORS = True` or `.iterator()` fails.
  • Idle-in-transaction is worse than a slow query: it holds the connection *and* the locks.

Pro tip

  • Leave headroom in the budget. The connection you cannot get is usually the one a migration or an incident shell needed.
  • A pooler cannot recycle a connection a transaction refuses to release — do slow work before opening the transaction, never inside it.

Next up

  • Read Replicas
  • Observability
  • Structured Logging

Sources

  • Databases reference — docs.djangoproject.com/en/5.2/ref/databases/
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.