Managing Database Connections in Django Async Views

This guide is part of Django Database Connection Management. Moving a Django project from WSGI to ASGI and rewriting hot views as async def changes how database connections are opened, even though the ORM calls look almost the same. Django’s database connections are bound to threads. Async ORM methods such as aget() and acount() run the synchronous ORM in a worker thread, and under ASGI each request can get its own such thread. A service that held a steady 20 connections under Gunicorn threads can open one per concurrent request under Uvicorn, and with persistent connections enabled some of them are never closed:

django.db.utils.OperationalError: connection failed: FATAL:  remaining connection slots are reserved
for roles with the SUPERUSER attribute
psycopg_pool.PoolTimeout: couldn't get a connection after 10.00 sec

The objective is to understand which thread and connection each async ORM call uses, remove the persistent-connection settings that leak under ASGI, and bound the connection count with a pool.

Rapid incident diagnosis

Count connections per worker process. With application_name set in OPTIONS, PostgreSQL shows how many sessions each Uvicorn or Daphne process holds and whether they are idle:

SELECT client_addr, client_port / 1000 AS port_block, state, count(*)
FROM pg_stat_activity
WHERE application_name = 'shop-asgi'
GROUP BY client_addr, port_block, state
ORDER BY count(*) DESC;

If idle sessions keep accumulating after traffic falls, connections opened by request threads are outliving their requests.

Check the settings that decide connection lifetime.

from django.conf import settings
db = settings.DATABASES["default"]
print(db.get("CONN_MAX_AGE"), db.get("CONN_HEALTH_CHECKS"), db.get("OPTIONS", {}).get("pool"))
  • CONN_MAX_AGE > 0 with no pool under ASGI: connections are kept per thread, but the threads async ORM calls run on are not the long-lived request threads that WSGI provided. Django’s request-finished cleanup can miss connections created in those threads, so idle connections accumulate.
  • CONN_MAX_AGE = None (unlimited persistence) makes the accumulation permanent until the worker restarts.
  • A pool configured ("pool": True or a dict) with CONN_MAX_AGE = 0: connections are bounded by the pool’s max_size per process, and a pool timeout means the pool is saturated, not leaking.

Look for synchronous ORM use in async code. A SynchronousOnlyOperation exception is Django refusing a sync ORM call from the event loop. Wrapping such calls with sync_to_async(..., thread_sensitive=False) runs them in a general thread pool, where each thread opens its own connection — a frequent source of extra connections:

grep -rn "thread_sensitive=False" --include=*.py .
Thread and connection mapping for Django async views An ASGI worker process runs an event loop serving three concurrent requests. Each request's async ORM calls run in its own thread-sensitive executor thread, and each of those threads holds a separate database connection. Uvicorn worker one event loop 3 concurrent async views request 1 · worker thread request 2 · worker thread request 3 · worker thread aget() connection 1 connection 2 connection 3 connections per process track concurrent requests that touch the database
Async ORM calls run synchronous ORM code in a request-scoped worker thread. Each thread that touches the database holds its own connection, so concurrency, not the number of processes, sets the connection count.

Mathematical sizing or parameter formula

Without a pool, the connection count of one ASGI process is bounded only by concurrent requests that reach the ORM:

connections_per_process ≈ concurrent_requests_using_orm

With Django’s psycopg 3 pool (Django 5.1 and later), each process is bounded by max_size, and requests beyond it wait up to timeout seconds:

fleet_connections = pods × uvicorn_workers × max_size

Size max_size from the ORM time per request rather than from total concurrency, because an async view often spends most of its time awaiting other I/O. Each async ORM call borrows a connection for the duration of the thread-sensitive section and returns it at the end of the request:

max_size = ceil( requests_per_second_per_process × orm_seconds_per_request ÷ 0.7 )

Worked example. Each Uvicorn worker handles 300 requests per second with 150 concurrent in flight, because views await an external pricing API for most of their 500 ms. Only 25 ms of each request is ORM work. Without a pool the process can approach 150 connections; with a pool, ceil(300 × 0.025 ÷ 0.7) = 11 connections per process suffice. Four pods with two workers each claim 4 × 2 × 11 = 88 connections instead of up to 1,200.

This formula assumes connections return to the pool promptly. In Django, a pooled connection used by a request is returned when the request finishes, so a view that does its ORM work early and then awaits a slow API for 475 ms keeps the connection checked out for the whole request. Restructure such views so ORM work finishes in a helper that closes its connection, or accept orm_seconds_per_request equal to the whole request time in the formula.

Fleet connections with and without a pool for async views For four pods with two Uvicorn workers each, the unpooled worst case is 1,200 connections at 150 concurrent requests per worker, compared with 88 using a pool of max_size 11 per worker. Connections from 4 pods × 2 Uvicorn workers max_connections 300 no pool: up to 1,200 pool max_size 11: 88 150 concurrent requests per worker against 25 ms of ORM time per request
Async views make concurrency cheap, and without a pool every concurrent request can hold a connection. A pool sized by ORM time bounds the fleet to a small fraction of that.

Exact remediation & configuration

Turn off persistent connections and enable the pool. Django rejects pooling combined with persistent connections, which is the correct pairing for ASGI anyway:

# settings.py — Django 5.1+, psycopg 3 with psycopg-pool installed
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "shop",
        "USER": "shop_app",
        "HOST": "shop-db.internal",
        "CONN_MAX_AGE": 0,              # required with pooling; correct under ASGI
        "CONN_HEALTH_CHECKS": False,    # the pool checks connections itself
        "OPTIONS": {
            "application_name": "shop-asgi",
            "pool": {
                "min_size": 2,
                "max_size": 11,
                "timeout": 5,           # seconds to wait for a connection, below the request deadline
                "max_idle": 300,        # close idle connections above min_size after 5 minutes
                "max_lifetime": 1800,   # recycle connections every 30 minutes
            },
        },
    }
}

On Django versions before 5.1, the equivalent is CONN_MAX_AGE = 0 plus an external pooler such as PgBouncer, because Django opens and closes a connection per request and the pooler absorbs the cost. The PgBouncer configuration is in Configuring CONN_MAX_AGE for Django and PgBouncer.

Keep ORM work in thread-sensitive sections. Use the native async ORM methods, or sync_to_async with the default thread_sensitive=True, so all ORM calls in a request share one thread and one connection:

from asgiref.sync import sync_to_async
from django.http import JsonResponse

@sync_to_async                          # thread_sensitive=True by default
def load_cart(cart_id):
    cart = Cart.objects.select_related("customer").get(pk=cart_id)
    return cart, list(cart.items.values("sku", "qty"))

async def cart_view(request, cart_id):
    cart, items = await load_cart(cart_id)                       # one borrow, one thread
    prices = await pricing_client.quote([i["sku"] for i in items])  # no ORM here
    return JsonResponse({"customer": cart.customer.name, "items": items, "prices": prices})

Do not fan ORM calls out across threads. asyncio.gather over several aget() calls in one request does not run them in parallel — thread-sensitive calls queue on the same thread — and forcing parallelism with thread_sensitive=False opens one connection per general-pool thread. Combine the queries instead, or use select_related, prefetch_related and in_bulk.

Close connections in long-lived async tasks. Code that runs outside the request cycle — a background asyncio task started at startup, a management command using async ORM calls, a Channels consumer — does not receive request_finished, so connections are never returned. Close them explicitly:

from django.db import close_old_connections

async def refresh_catalog_forever():
    while True:
        await sync_to_async(rebuild_catalog_snapshot)()
        await sync_to_async(close_old_connections)()   # return this thread's connection
        await asyncio.sleep(60)

Bound worker concurrency. Uvicorn’s --limit-concurrency rejects requests above a threshold per worker with HTTP 503, which caps how many requests can wait on the pool at once:

uvicorn shop.asgi:application --workers 2 --limit-concurrency 400 --timeout-graceful-shutdown 20
Connection lifecycle for an async request and a background task An async request borrows a pooled connection on its first ORM call and returns it when Django sends request_finished. A background asyncio task has no request_finished signal and must call close_old_connections to return its connection. ASGI request first aget() Pool checkout max_size 11 request_finished connection returned to pool Background task no request cycle Pool checkout same pool No signal: held forever unless close_old_connections()
In the pooled setup, a request borrows once and returns at request_finished. Background tasks outside the request cycle must return connections themselves.

Validation & verification

Connection ceiling under concurrency. Load one worker with far more concurrent requests than max_size and confirm the session count from that process never exceeds the pool maximum:

hey -z 120s -c 300 https://staging.shop.internal/cart/42/ > /dev/null &
watch -n 5 "psql -h shop-db.internal -U ops -Atc \"SELECT count(*) FROM pg_stat_activity WHERE application_name='shop-asgi'\""

With 4 pods × 2 workers × 11, the count must stay at or below 88.

No accumulation after traffic. Stop the load. Within max_idle seconds, idle sessions per process should fall to min_size, and no session should remain idle in transaction:

SELECT state, count(*) FROM pg_stat_activity WHERE application_name = 'shop-asgi' GROUP BY state;

Pool wait is short. Enable the pool’s logger and check that waits are rare and short:

LOGGING = {
    "version": 1,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "loggers": {"psycopg.pool": {"handlers": ["console"], "level": "INFO"}},
}

PoolTimeout must not appear during the load test. If it does while PostgreSQL shows most sessions idle, views are holding connections through non-ORM awaits, and the formula’s ORM time should be the whole request time.

Background tasks return connections. Run the background task for ten iterations with a pool max_size of 2 while making requests; requests must continue to be served, proving the task is not holding a connection between iterations.

Streaming responses, WebSockets and long-lived async work

Request-scoped cleanup assumes a request finishes quickly. Three async patterns break that assumption and hold connections far longer than the view appears to.

Streaming responses. StreamingHttpResponse with an async iterator keeps the request open until the last chunk is sent. If the iterator issues ORM queries as it goes, a pooled connection stays checked out for the whole stream — seconds or minutes for large exports. Fetch the data up front in a thread-sensitive block and stream from memory, or fetch in bounded pages and close the connection between pages:

from django.db import close_old_connections

async def export_orders(request):
    async def rows():
        last_id = 0
        while True:
            batch = await sync_to_async(list)(
                Order.objects.filter(id__gt=last_id).order_by("id").values_list("id", "total")[:1000])
            await sync_to_async(close_old_connections)()     # release between pages
            if not batch:
                break
            last_id = batch[-1][0]
            for order_id, total in batch:
                yield f"{order_id},{total}\n"
    return StreamingHttpResponse(rows(), content_type="text/csv")

WebSocket consumers. Django Channels consumers live for the duration of the socket. Database access in a consumer runs outside the HTTP request cycle, so request_finished never fires. Use database_sync_to_async, which closes old connections around each call, rather than bare sync_to_async.

Server-sent events and long polling. An endpoint that waits for events should never hold a connection while waiting. Query, release, wait, query again — the same paging pattern as streaming.

Pattern Connection held by default Safe pattern
Normal async view For the request Default behaviour is fine
StreamingHttpResponse with ORM in the iterator For the whole stream Page and release between pages
Channels consumer Until manually closed database_sync_to_async
Long polling or server-sent events For the whole wait Release before waiting
Background asyncio task Indefinitely close_old_connections per iteration

With these patterns, the pool’s max_size bounds actual database work, and long-lived connections from clients — sockets, streams — no longer translate into long-lived database connections.

Frequently Asked Questions

Does Django’s async ORM use an async database driver?
Not in the core ORM. Async query methods run the synchronous ORM in a thread through sync_to_async, and the driver is still used synchronously. That is why thread-to-connection mapping matters for async views.
Can I keep CONN_MAX_AGE under ASGI if I do not use the pool?
Django’s documentation advises disabling persistent connections in async mode. Connections created in worker threads may not be cleaned up by the request lifecycle, and the result is gradual accumulation. Use CONN_MAX_AGE = 0 with a pool or an external pooler.
Is CONN_HEALTH_CHECKS useful with the pool?
The psycopg pool checks connections when they are returned and can be configured to check on checkout, so Django’s own health checks add little. They matter for non-pooled persistent connections, which you should not be using under ASGI.
How do Celery workers fit in?
Celery workers are synchronous processes with their own connection lifecycle, independent of the ASGI server. Keep their settings in a separate settings module if they differ, and follow Preventing Django Connection Leaks During Celery Tasks.
Does database_sync_to_async work with the psycopg pool?
Yes. It calls close_old_connections before and after the wrapped function, which returns the pooled connection at the end of each call rather than at the end of the WebSocket session.