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.

Where pooling happensDjangorequest handlerPoolpsycopg or PgBouncerPostgreSQLbackend processReuseinstead of reconnectPooling removes connection setup cost and caps the load on the database.

Quick answer

  1. Use Django 5.1 or newer with psycopg 3, and install psycopg[pool] (or psycopg-pool).
  2. Set "OPTIONS": {"pool": {...}} with min_size, max_size, and timeout.
  3. Keep CONN_MAX_AGE = 0; Django raises ImproperlyConfigured if pooling is combined with persistent connections.
  4. Set CONN_HEALTH_CHECKS = True to enable the pool's connection check when lending connections.
  5. Size max_size per process: total connections become processes × max_size.
  6. Avoid database access at import time with Gunicorn --preload, so pools are created after forking.
  7. Verify with pg_stat_activity and 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,
            },
        },
    }
}
the pool settings, one by one"CONN_MAX_AGE":0,"OPTIONS": {"pool": {"min_size": 2,"max_size": 6,"timeout":10,"max_idle": 300, "max_lifetime":1800,}},12341Required: the pool replaces persistent connections.2Per process: multiply by workers and servers.3Seconds to wait for a free connection before PoolTimeout.4Recycle connections so failovers and leaks heal.Needs Django 5.1+ and psycopg 3 with the pool extradjango-deployment.com
Install with pip install "psycopg[pool]".

"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 raising PoolTimeout.
  • max_idle: seconds after which idle connections above min_size are 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
the pool is per processOne worker, max_size 66One server, 4 workers24Three servers72max_connections1000255075100connectionsCelery workers and deploy overlap need room toodjango-deployment.com
Size max_size to the threads per worker, not to a round number.

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 = 1 or 2 avoids reconnect costs; larger pools add nothing but idle connections.
  • gthread workers: set max_size close to the number of threads, or slightly below if not every request uses the database; requests wait up to timeout when the pool is exhausted.
  • ASGI (Uvicorn, Daphne): Django runs sync ORM calls in a thread pool; size max_size to 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.

pools must be created after the forkCreated in the master (--preload)✕sockets shared by every worker✕corrupted protocol state✕random connection errorsCreated in each worker✓no queries at import time✓pool opens on first use✓or close pools in post_forkA query during import is enough to open the pool in the masterdjango-deployment.com
Without --preload, each worker imports Django separately and this cannot happen.
  • 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 in AppConfig.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_size is too small for the thread count
  • the database is overloaded, so every operation takes longer
when every connection is busySlow query holds 6all connections busyRequest 7 waitsqueue formstimeout reachedafter 10 sPoolTimeoutrequest fails, 500Fix the causeslow query or long transactionA bigger pool hides the problem until the database runs out insteaddjango-deployment.com
pg_stat_activity shows which queries are holding connections.

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?

NeedBuilt-in poolPgBouncer
Reduce connection setup latencyyesyes
Cap connections per processyesnot directly
Cap total connections across many serversnoyes
No extra service to operateyesno
Works for non-Django clients toonoyes
Transaction pooling across processesnoyes

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 ImproperlyConfigured at startup (pooling with CONN_MAX_AGE = 0)
  • no PoolTimeout errors 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 pool while leaving CONN_MAX_AGE at a positive value.
  • Setting max_size to 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

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.

Learning path · step 9 of 12

Making Django fast

Keep going