Enabling Django Connection Pooling with psycopg3

This guide is part of Django Database Connection Management. For most of Django’s history the framework had no connection pool at all: CONN_MAX_AGE let a worker process keep its own connection between requests, and that was the whole story. Django 5.1 added a real pool, built on psycopg_pool, and it changes the arithmetic enough to be worth understanding precisely.

The distinction matters because the two mechanisms solve different problems. CONN_MAX_AGE avoids reconnect cost within one worker. A pool lets a worker’s threads share a set of connections and, critically, lets the connection count be smaller than the thread count. In a threaded deployment that is the difference between one connection per thread and one connection per concurrent query.

Requirements and configuration

The pool requires Django 5.1 or later, psycopg 3, and the psycopg-pool package:

pip install "psycopg[binary,pool]>=3.2"

Then enable it in DATABASES under OPTIONS:

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "orders",
        "USER": "orders_app",
        "HOST": "db.internal",
        "PORT": 5432,

        # Must be 0 when using the pool — see below.
        "CONN_MAX_AGE": 0,

        "OPTIONS": {
            "pool": {
                "min_size": 2,
                "max_size": 8,
                "timeout": 5,             # seconds to wait for a connection
                "max_lifetime": 280,      # below the load balancer idle timer
                "max_idle": 120,
                "num_workers": 2,
            },
            # Server-side statement timeout on every connection.
            "options": "-c statement_timeout=8000",
        },
    }
}

# Passing True uses psycopg_pool defaults instead of an explicit dict:
#   "OPTIONS": {"pool": True}

The CONN_MAX_AGE: 0 line is mandatory, not stylistic. Django raises ImproperlyConfigured if both the pool and a non-zero CONN_MAX_AGE are set, because they are competing mechanisms for the same thing: CONN_MAX_AGE tells Django to hold a connection open across requests, while the pool wants the connection returned so another thread can use it.

A second hard constraint: the pool is only supported on the postgresql backend, and only with psycopg 3. It is not available for MySQL, SQLite, or Oracle, and it does not work with psycopg2. Check django.db.connection.connection returns a psycopg 3 connection if you are unsure which driver is actually in use after an upgrade.

Per-thread connections versus a shared pool Top: with CONN_MAX_AGE each of eight threads holds its own persistent connection, so the worker consumes eight backends. Bottom: with the pool the same eight threads share a pool of three, so the worker consumes three backends and threads queue briefly when all are lent out. The pool decouples connection count from thread count CONN_MAX_AGE, threaded worker 8 request threads each keeps its own connection 8 backends per worker process held even while threads are idle pool, same worker 8 request threads borrow only while querying 3 backends per worker process threads queue briefly at peak The saving is real only if threads spend most of their time outside the database — which they usually do.
With CONN_MAX_AGE the worker's backend count equals its thread count. With the pool it equals the concurrent query count.

Sizing it for your deployment

The pool is per process, so total connections are processes x max_size. The multiplier depends on how you run Django:

Deployment Processes per host What max_size should reflect
Gunicorn sync workers workers 1 — a sync worker runs one request at a time
Gunicorn gthread workers concurrent queries per worker, below threads
uWSGI with threads processes same as gthread
ASGI under uvicorn workers concurrent database operations, not total tasks
Celery prefork concurrency 1 per child process
Celery threads/gevent concurrency groups concurrent queries per worker process

With Gunicorn sync workers there is no benefit to a pool larger than 1, and honestly little benefit to the pool at all — a sync worker handles one request at a time, so CONN_MAX_AGE achieves the same thing more simply. The pool earns its place under gthread, uWSGI threads, or ASGI, where one process runs many concurrent requests.

The arithmetic that matters is the total against the database ceiling:

total = hosts x workers_per_host x max_size
      =   6   x        4         x    8      = 192 backends

Against a max_connections of 200 that is already over budget once you account for reserved slots and migrations. Either max_size comes down to 5, or a proxy multiplexes the fleet — the reasoning is in tuning PostgreSQL max_connections and superuser_reserved_connections.

Set min_size low. Every process holds min_size connections permanently, so min_size: 5 across 24 processes is 120 idle backends doing nothing overnight. Two is usually plenty to avoid a cold-start penalty on the first request after a quiet period.

The pool only helps where a process runs concurrent work A synchronous worker handles one request at a time, so there is nothing for a pool to share. A threaded or ASGI worker handles many, so a pool smaller than the concurrency level is a genuine saving. Whether the pool helps depends on the worker model sync workers one request per process at a time max_size above 1 is never used CONN_MAX_AGE achieves the same thing the pool adds machinery, not savings threaded or ASGI many concurrent requests per process most time spent outside the database max_size well below thread count this is where the pool pays Count concurrent database operations, not concurrent requests. In an async service the two differ by an order of magnitude. Measuring it beats guessing: instrument checkout duration and take the p95 of concurrent checkouts.
The pool earns its place only where one process runs concurrent requests. Under sync workers it does not.

Behaviour you need to know about

The pool is created lazily and does not block startup. psycopg_pool opens connections in background workers (num_workers), so the first requests after a deploy may wait on the timeout while the pool fills. This is normally fine; if it is not, warm the pool in a readiness check.

timeout is the acquisition timeout. When it expires, psycopg raises PoolTimeout, which Django surfaces as an OperationalError. Set it below your request deadline so a saturated pool produces a fast error rather than a hung worker. Five seconds is a reasonable default.

max_lifetime is the connection age cap. Set it below the shortest idle timer in the network path, exactly as for any other pool. Behind a load balancer with a 350-second idle timeout, 280 is right; the general rule is in tuning HikariCP maxLifetime and idleTimeout, and the reasoning transfers directly.

Transactions still bound the borrow. Django returns the connection to the pool at the end of the request (or at the end of the atomic block under ATOMIC_REQUESTS). A view that calls an external API inside transaction.atomic() holds a pooled connection for the duration of that call, which is now worse than before — it starves other threads rather than merely occupying its own connection.

ATOMIC_REQUESTS interacts badly with a small pool. With ATOMIC_REQUESTS = True, every request holds a connection for its entire duration, including template rendering and any non-database work. That erases most of the pool’s benefit. Prefer explicit transaction.atomic() blocks scoped tightly around the writes.

Common failure patterns and remediation

Symptom Underlying cause Remediation
ImproperlyConfigured at startup pool set with a non-zero CONN_MAX_AGE Set CONN_MAX_AGE to 0
ImproperlyConfigured: pool is not supported Not on the PostgreSQL backend, or psycopg2 still installed Install psycopg 3 with the pool extra; confirm the backend
OperationalError under load, database idle Pool timeout firing; max_size too small for concurrency Raise max_size if the database has headroom, or reduce hold time
Pool benefit invisible under Gunicorn sync One request per worker; nothing to share Use gthread or ASGI, or stay with CONN_MAX_AGE
Connections held for whole requests ATOMIC_REQUESTS = True Scope transactions explicitly around writes
Idle backends far above expectation min_size multiplied by process count Lower min_size to 1 or 2
Stale connection errors after quiet periods max_lifetime above an upstream idle timer Lower max_lifetime below the shortest timer in the path
Celery workers exhaust the ceiling Pool configured per worker child as well as per web process Give Celery its own DATABASES alias with a smaller pool

Celery and management commands

Background workers need separate treatment, because they multiply process count without adding request throughput. Give them their own database alias with a smaller pool and route tasks to it:

DATABASES["worker"] = {
    **DATABASES["default"],
    "OPTIONS": {
        **DATABASES["default"]["OPTIONS"],
        "pool": {"min_size": 0, "max_size": 2, "timeout": 10, "max_lifetime": 280},
    },
}

class WorkerRouter:
    def db_for_read(self, model, **hints):
        return "worker" if getattr(_local, "in_task", False) else None
    db_for_write = db_for_read

min_size: 0 is deliberate here. A Celery worker that is idle between tasks should hold nothing, and the reconnect cost on the next task is irrelevant against task latency measured in seconds.

Management commands and migrations are the other case. A migration that runs while every web process holds max_size connections may find no slot available at all. Run migrations against a dedicated role with its own reserved capacity, and keep the fleet’s total comfortably below the ceiling so there is always room.

Every process type adds to the same total Three contributors stacked against the database connection budget: web processes multiplied by pool max size, Celery workers multiplied by their pool size, and operational connections for migrations and monitoring. Web, workers and operations all draw on one budget effective ceiling after reserved slots — 185 web: 6 hosts x 4 workers x max_size 5 = 120 workers: 16 x max_size 2 = 32 ops and migrations = 25 Total 177 against 185 — workable, with almost no room for a scale-up. Doubling web hosts during a traffic event would exhaust the ceiling before CPU became a problem, which is why autoscaling limits must be derived from the connection budget as well as from load. A proxy in transaction mode removes this constraint entirely at the cost of session-level features.
Web processes, background workers and operational tooling all consume the same ceiling. Size all three together.

When a proxy is still the better answer

Django’s pool is per process. That is its structural limit: it cannot reduce the fleet’s total below processes x min_size, and process count is set by your deployment, not by your database. Past a few dozen processes the arithmetic stops working no matter how small you make max_size.

A proxy in transaction mode collapses the whole fleet onto a few dozen backends. The cost is that session-level features stop working, and Django uses some of them — most notably server-side cursors, which QuerySet.iterator() uses by default on PostgreSQL. Under transaction pooling those break, and DISABLE_SERVER_SIDE_CURSORS = True is required:

DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True

Set that whenever Django talks to a transaction-mode proxy. The wider set of constraints is described in Transaction vs Statement Pooling Tradeoffs. The two layers compose fine — a small Django pool per process in front of a proxy is a reasonable production shape — but only one of them can be responsible for keeping the total inside the ceiling.

Operational boundary

The pool reduces connections per process; it does not reduce hold time. A view that opens a transaction and then calls a payment API holds a pooled connection for the length of that call, and with a small pool that now blocks other threads in the same process. Auditing for work done inside a transaction is more important with a pool than without one, not less.

It also does not make Django async-safe in ways it was not before. Under ASGI, database access still happens in a thread executor, and the pool size caps concurrent database work in that executor. Sizing it as though every async task could query simultaneously produces a number far larger than the database can support.

Frequently Asked Questions

Which Django version added this?
Django 5.1. It requires psycopg 3 and the psycopg-pool package, and is supported only on the PostgreSQL backend.
Can I use the pool with CONN_MAX_AGE?
No. Django raises ImproperlyConfigured if both are set. The pool replaces CONN_MAX_AGE; set it to 0.
Is the pool shared across worker processes?
No. It is per process. Total connections are process count multiplied by max_size, which is the number that must fit inside the database ceiling.
Does it help with Gunicorn sync workers?
Barely. A sync worker handles one request at a time, so there is nothing to share. The pool pays off under threaded or ASGI deployments.
What happens when the pool is exhausted?
After timeout seconds psycopg raises PoolTimeout, surfaced as an OperationalError. Set timeout below your request deadline so the failure is fast and clean.
Do I still need PgBouncer?
If your process count is large, yes. Django’s pool cannot collapse connections across processes; a proxy can. The two work together, with the proxy owning the total.
What breaks under a transaction-mode proxy?
Server-side cursors, which QuerySet.iterator() uses by default. Set DISABLE_SERVER_SIDE_CURSORS = True. Session-scoped SET statements and advisory locks held across statements also stop behaving as expected.