When sizing worker processes and threads for a Django deployment, how does Django's one-connection-per-thread model limit the numbers?
answer
- connections belong to threads
- instances times processes times threads
- CONN_MAX_AGE keeps idle ones open
- a pool per process since 5.1
- the jobs outside the web tier
basics
~20 sDjango opens a separate database connection in every thread that queries, so each worker thread in each process and instance can hold one. The database's connection limit therefore caps processes times threads, and persistent connections keep idle workers' connections open too.
solid answer
~40 sDjango's connections are thread-local: each thread opens its own on its first query. With `CONN_MAX_AGE` at its default `0` it is closed at the end of each request; with a positive value or `None` it stays open between requests, so idle threads keep holding connections. Django's docs put it directly: the database must support at least as many simultaneous connections as you have worker threads — in practice instances × processes × threads, plus management commands, scheduled jobs and task workers. Django 5.1's PostgreSQL `pool` option keeps one pool per process per alias, so the budget becomes processes × pool size, and it raises `ImproperlyConfigured` if combined with a non-zero `CONN_MAX_AGE`. Under ASGI, sync ORM work runs on per-request threads, so size the pool for concurrent requests.
go deeper
Remember that each Django worker thread uses its own database connection, so more workers means more connections.
Explain CONN_MAX_AGE's default of 0, what persistent connections change, and why the docs require at least as many database connections as worker threads.
Build the connection budget across instances, processes, threads and non-web consumers, and choose between persistent connections, the 5.1 pool and an external pooler.
Set fleet-wide connection budgets per database, decide where pooling lives, and make worker counts a function of that budget rather than of CPU cores alone.
## Django's connection model Django does not share a database connection between threads. The connection handler keeps one connection object **per thread, per database alias**, opened lazily on the first query in that thread. At the start and end of every request Django checks the connection's age: with **`CONN_MAX_AGE = 0`** (the default) it is closed when the request finishes; with a positive number of seconds it is reused until it reaches that age; with `None` it is kept indefinitely. The consequence for capacity is simple and easy to forget: **every thread that serves requests is a potential open connection**. ## Counting the budget The database server has a hard connection limit. To stay under it: 1. Multiply **instances × worker processes per instance × threads per process** for the web tier. With a pre-fork WSGI server and no threads, that is instances × processes. 2. Add everything else that talks to the same database: task workers and their concurrency, scheduled jobs, one-off `manage.py` commands such as `migrate`, admin shells and monitoring. 3. Keep headroom for deploys, when old and new workers overlap for a while. 4. If the total exceeds the database's limit, reduce threads or processes, add a pool, or put an external pooler in front of the database. | Configuration | Connections held by the web tier | |---|---| | `CONN_MAX_AGE = 0`, no pool | up to one per *busy* thread; closed after each request | | `CONN_MAX_AGE > 0` or `None`, no pool | up to one per thread, busy or idle, until the age expires | | PostgreSQL `pool` option (5.1+) | per process, bounded by that pool's size; `CONN_MAX_AGE` must stay `0` | | external pooler in transaction mode | as many as the pooler allows; server-side cursors must be disabled for that alias | The development server is a special case the docs call out: it creates a new thread per request, which defeats persistent connections, so they should not be enabled in development. ## Pools and persistent connections Django 5.1 added a **connection pool** for PostgreSQL with psycopg 3: set `"pool": True` (or a dict of pool options) in the alias's `OPTIONS`. Two details matter for sizing: - the pool is created **per process and per alias**, so ten worker processes mean ten pools; - combining it with a non-zero `CONN_MAX_AGE` raises `ImproperlyConfigured` (*Pooling doesn't support persistent connections.*). A pool lets threads borrow connections instead of each owning one for the life of the thread, which turns the bound from *threads* into *pool size per process*. ## ASGI changes the shape, not the rule Under ASGI, one worker process can hold many requests at once. Sync ORM code runs through `sync_to_async` in thread-sensitive mode: calls within one request share that request's worker thread, but concurrent requests each get their own, so concurrent database work still needs one connection per busy request. The docs therefore recommend disabling persistent connections under ASGI and sizing a pool to the concurrency you expect, rather than disabling thread sensitivity. ## Tuning moves - Count every extra thread per process as an extra potential connection before adding it, especially when most views hit the database. - Set `CONN_MAX_AGE` low or `0` for aliases that most requests never touch. - Enable `CONN_HEALTH_CHECKS` when reusing connections, so a connection dropped by the server is replaced instead of failing a request. - Watch the database's active-connection count during deploys and traffic peaks, not just at rest. ## What interviewers listen for The thread-local model, a formula that includes non-web consumers, the effect of `CONN_MAX_AGE`, and awareness that 5.1's pool is per process and incompatible with persistent connections.
- Why do the docs say not to enable persistent connections in development?The development server creates a new thread for every request. Because connections belong to threads, each request would open a fresh connection anyway, so persistence buys nothing and only leaves connections behind until they age out.
- What happens if you enable the PostgreSQL pool option but leave CONN_MAX_AGE at 600?Django raises `ImproperlyConfigured` with the message that pooling doesn't support persistent connections when the pool is first created. The two mechanisms solve the same problem in conflicting ways, so `CONN_MAX_AGE` must be `0` for an alias that uses the pool.
Each worker thread is a cashier who, on the first sale, is issued a personal till. With CONN_MAX_AGE above zero, cashiers keep their till even while nobody is at their counter; a pool is one rack of tills per shop (per process) that cashiers borrow from and return, so each shop's rack size, not its cashier count, bounds the tills in use.
saying these in an interview costs you the question
- Django shares one database connection across all threads of a process
- CONN_MAX_AGE defaults to a persistent connection of 600 seconds
- Only web workers count toward the database connection limit
- The 5.1 PostgreSQL pool is shared by all worker processes
- Under ASGI one connection per process is enough, whatever the concurrency