Use Django's Built-In PostgreSQL Connection Pool
Enable Django's native psycopg 3 connection pool: sizing per process, CONN_MAX_AGE rules, health checks, Gunicorn and ASGI behavior, and PgBouncer.
Problem statement
Django 5.1 added built-in connection pooling for PostgreSQL through psycopg 3's psycopg_pool. Before that, Django projects had two options: open a connection per request (CONN_MAX_AGE = 0) or keep one connection per thread (CONN_MAX_AGE > 0). Neither fits every deployment well:
- per-request connections pay connection setup cost on every request
- persistent per-thread connections hold a connection for every thread even when idle, and Django recommends against them under ASGI
A pool keeps a bounded set of open connections per process and lends them to threads or async tasks as needed. It is a single settings change, but it is easy to misunderstand the scope: the pool lives inside each process, so it does not reduce total connections across many processes the way an external pooler such as PgBouncer does.
This guide enables the built-in pool, sizes it, and explains how it behaves with Gunicorn, ASGI servers, and Celery.
Quick answer
- Use Django 5.1 or newer with psycopg 3, and install
psycopg[pool](orpsycopg-pool). - Set
"OPTIONS": {"pool": {...}}withmin_size,max_size, andtimeout. - Keep
CONN_MAX_AGE = 0; Django raisesImproperlyConfiguredif pooling is combined with persistent connections. - Set
CONN_HEALTH_CHECKS = Trueto enable the pool's connection check when lending connections. - Size
max_sizeper process: total connections become processes ×max_size. - Avoid database access at import time with Gunicorn
--preload, so pools are created after forking. - Verify with
pg_stat_activityand pool-related errors in logs.
Step-by-step solution
1. Install the pool package
Django==5.2.6
psycopg[binary,pool]==3.2.10
Or add psycopg-pool separately. With psycopg2, the pool option is ignored.
Confirm:
python -c "import psycopg_pool, psycopg; print(psycopg.__version__, psycopg_pool.__version__)"
2. Enable the pool
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": 0,
"CONN_HEALTH_CHECKS": True,
"OPTIONS": {
"pool": {
"min_size": 2,
"max_size": 6,
"timeout": 10,
"max_idle": 300,
"max_lifetime": 1800,
},
},
}
}
"pool": True uses ConnectionPool defaults; a dict passes keyword arguments to psycopg_pool.ConnectionPool:
min_size: connections kept open even when idle.max_size: maximum connections this process may open.timeout: seconds a request waits for a free connection before raisingPoolTimeout.max_idle: seconds after which idle connections abovemin_sizeare closed.max_lifetime: connections are replaced after this age, which helps with failovers and memory growth on the server side.
With CONN_HEALTH_CHECKS = True, Django configures the pool to check connections when they are lent out, so a connection broken by a database restart is replaced instead of handed to your code.
3. How Django uses the pool
In a request, Django takes a connection from the pool on first database access and returns it to the pool when the connection is closed at the end of the request. Other threads in the same process can then reuse it. The pool is created lazily per database alias on first use, not at import time.
Transactions work as usual: a connection stays with the request for the duration of transaction.atomic() blocks and is returned afterwards.
4. Size the pool per process
The pool belongs to one process. Total PostgreSQL connections are:
(gunicorn workers × max_size) per server × servers
+ celery worker processes × max_size (if they use pooling)
+ other processes
Guidelines:
- Sync Gunicorn workers without threads: each worker handles one request at a time and needs at most one connection. A pool of
max_size = 1or2avoids reconnect costs; larger pools add nothing but idle connections. gthreadworkers: setmax_sizeclose to the number of threads, or slightly below if not every request uses the database; requests wait up totimeoutwhen the pool is exhausted.- ASGI (Uvicorn, Daphne): Django runs sync ORM calls in a thread pool; size
max_sizeto the number of concurrent database operations you expect per process.
If processes × max_size exceeds what the database can handle, use fewer processes, smaller pools, or an external pooler.
5. Gunicorn and forking
Pools use background threads for maintenance. Threads and open sockets do not survive fork() correctly, so a pool must be created in each worker, after forking.
- Without
--preload, workers import the app after forking, and the lazy pool is created on first database use in each worker. This is the safe default. - With
--preload, avoid database access during import (for example inAppConfig.ready(), module-level queries, or settings that query the database). If a connection is opened in the master before forking, workers inherit an invalid pool state.
If you need --preload and some startup code touches the database, close pools before workers fork using a Gunicorn server hook in gunicorn.conf.py:
def pre_fork(server, worker):
from django.db import connections
for conn in connections.all(initialized_only=True):
conn.close()
close_pool = getattr(conn, "close_pool", None)
if close_pool:
close_pool()
close_pool() exists on the PostgreSQL backend in Django 5.1 and later. connections.all(initialized_only=True) skips aliases that were never used.
6. ASGI deployments
The pool is the recommended approach for ASGI, where persistent connections should stay disabled:
uvicorn config.asgi:application --workers 2 --host 127.0.0.1 --port 8000
Each Uvicorn worker process gets its own pool. Async views that call the ORM through sync_to_async or the async ORM methods use pooled connections from worker threads.
7. Celery workers
Celery's prefork pool forks child processes, which create their own pools on first use. For long-running workers, pooling avoids reconnecting for every task. Size max_size small (often 1 for prefork children, since each child runs one task at a time), and keep max_lifetime finite so connections are refreshed periodically.
8. Handling pool exhaustion
When every connection is in use and timeout expires, psycopg raises psycopg_pool.PoolTimeout, which surfaces as an error in the request. Treat frequent timeouts as a capacity signal:
- slow queries or long transactions are holding connections
max_sizeis too small for the thread count- the database is overloaded, so every operation takes longer
Log and alert on PoolTimeout rather than raising timeout to large values, which only moves the waiting into user-facing latency.
9. Built-in pool or PgBouncer?
| Need | Built-in pool | PgBouncer |
|---|---|---|
| Reduce connection setup latency | yes | yes |
| Cap connections per process | yes | not directly |
| Cap total connections across many servers | no | yes |
| No extra service to operate | yes | no |
| Works for non-Django clients too | no | yes |
| Transaction pooling across processes | no | yes |
Many deployments combine them: PgBouncer in transaction mode centrally, and Django without its own pool (to avoid double pooling), or small built-in pools pointing at PgBouncer when connection setup to PgBouncer is itself expensive. Start with one layer and measure.
10. Verify
SELECT application_name, state, count(*)
FROM pg_stat_activity
WHERE usename = 'myapp'
GROUP BY 1, 2
ORDER BY 3 DESC;
python manage.py shell -c "
from django.db import connection
connection.ensure_connection()
print(type(connection.pool).__name__, connection.pool.get_stats())
connection.close()
"
get_stats() returns counters such as the pool size, available connections, requests waiting, and errors.
Verification check:
- total connections match processes × pool sizes and stay below the database limit
- no
ImproperlyConfiguredat startup (pooling withCONN_MAX_AGE = 0) - no
PoolTimeouterrors under normal peak traffic - after a database restart, requests recover without manual intervention
Explanation
A connection pool separates "a thread needs a connection now" from "a connection exists". Threads borrow connections briefly, and the pool keeps a limited, healthy set open. Django's integration uses psycopg's mature pool implementation and manages borrowing and returning at the same points where it previously opened and closed connections. Because the pool is per process, it improves latency and bounds per-process usage, while cross-process limits still depend on your process count or an external pooler.
Edge cases / notes
Multiple databases
Each alias in DATABASES with a pool option gets its own pool per process. Read replicas multiply connection counts accordingly.
Tests
Django's test framework handles pools, but tests that change database-related settings may need close_pool() calls to force new pools with new settings.
Session state
Connections are reused across requests. Anything a request sets on the session (for example SET statements) persists for the next borrower unless reset. Prefer role-level settings or transaction-local SET LOCAL.
Common mistakes
- Enabling
poolwhile leavingCONN_MAX_AGEat a positive value. - Setting
max_sizeto 20 per sync Gunicorn worker that only ever uses one connection. - Opening database connections at import time with
--preload. - Expecting the built-in pool to reduce total connections across many servers.
Next steps
- Compare with persistent connections in Configure Persistent Database Connections in Django.
- Pool across processes with Set Up PgBouncer for Django.
- Diagnose connection limits with Fix PostgreSQL "Too Many Connections" in Django.
- Install the right driver packages with Fix psycopg Installation Errors on Django Servers.
FAQ
Does Django have built-in database connection pooling?
Yes, since Django 5.1 for PostgreSQL with psycopg 3. Set "pool" in the database OPTIONS and install psycopg[pool].
Can I use CONN_MAX_AGE with Django's connection pool?
No. Pooling requires CONN_MAX_AGE = 0; Django raises ImproperlyConfigured with "Pooling doesn't support persistent connections" otherwise.
How big should the Django connection pool be?
Per process: about one connection per concurrent database-using thread. Sync Gunicorn workers need one or two; threaded or ASGI workers need more. Multiply by process count to check the database limit.
Should I use Django's pool or PgBouncer?
The built-in pool is simpler and reduces connection setup cost per process. PgBouncer is better when you must cap total connections across many servers or services.