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 > 0with 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": Trueor a dict) withCONN_MAX_AGE = 0: connections are bounded by the pool’smax_sizeper 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 .
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.
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
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?
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?
CONN_MAX_AGE = 0 with a pool or an external pooler.Is CONN_HEALTH_CHECKS useful with the pool?
How do Celery workers fit in?
Does database_sync_to_async work with the psycopg pool?
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.Related
- Django Database Connection Management — the parent guide to Django’s connection lifecycle.
- Enabling Django Connection Pooling with psycopg3 — the pool options used above in detail.
- Configuring CONN_MAX_AGE for Django and PgBouncer — the external-pooler alternative for older Django versions.
- Scoping Async SQLAlchemy Sessions in FastAPI — the equivalent problem with a natively async driver.