How to Load Test a Django App

To load test a Django app, first decide how it is served (Gunicorn sync or threaded workers under WSGI, or Uvicorn under ASGI). Then size workers × threads against your database connection limit, and set either CONN_MAX_AGE or Django's native psycopg pool so you are not opening a new PostgreSQL connection on every request. Script the test with a real session and CSRF token. While it runs, watch Gunicorn worker timeouts, database connection counts and queries per request. N+1 queries are the most common reason a Django app that feels fast falls over at modest RPS.
How Django handles concurrent requests
Django's concurrency comes from the application server in front of it:
- Gunicorn, sync workers (the default). Gunicorn's defaults are
workers = 1(or$WEB_CONCURRENCY),worker_class = sync,threads = 1,timeout = 30andbacklog = 2048. Each worker process handles one request at a time. Concurrency equals the number of workers. - Gunicorn, gthread workers. With
--threadsgreater than 1, each process handles several requests at once. Python's GIL means only one thread runs Python code at any moment, but threads waiting on the database or network release it. That makes threads useful for I/O-heavy views. - ASGI (Uvicorn, Daphne, Hypercorn). One event loop per process. Async views can wait on many I/O calls at once. Sync views and the sync ORM run in a thread. Django's async docs put the cost of each switch between styles at tens of microseconds per call, rarely visible alone but noticeable under GIL contention as active threads grow.
Two facts about connections drive most Django capacity problems. Django gives each thread its own database connection. And with the default CONN_MAX_AGE = 0, that connection is opened at the start of each request and closed at the end. That is a TCP plus TLS plus authentication handshake on every request, which PostgreSQL handles by forking a backend process.
What usually breaks first in a Django app
Worker timeouts
When all sync workers are busy, new requests wait in the socket backlog. If a single request runs longer than timeout (30 s by default), the arbiter kills the worker and logs:
[CRITICAL] WORKER TIMEOUT (pid:41237)
[ERROR] Worker (pid:41237) was sent SIGKILL! Perhaps out of memory?
The proxy in front then returns a 502 or a 504. Raising the timeout hides the problem. The question is why a request took 30 seconds, and the answer is almost always a query or an outbound HTTP call.
PostgreSQL connection limits
PostgreSQL's default max_connections is 100. Once workers × threads × servers goes over it, you see FATAL: sorry, too many clients already or remaining connection slots are reserved. With the psycopg pool, the error moves to the app side: psycopg_pool.PoolTimeout: couldn't get a connection after 30.00 sec. Both are covered in database connection pool exhausted.
N+1 queries
A list view that loops over 50 orders and touches order.customer.name runs 51 queries. At 1 ms each on an idle database, that is a 51 ms page and nobody notices. Under load, each query waits for a connection, a CPU and buffer cache, and those waits add up 51 times per request. The page reaches 2 s long before CPU on the app server is busy. Fix it with select_related for foreign keys and prefetch_related for reverse and many-to-many relations, and lock in the count with assertNumQueries.
Database-backed sessions
The default SESSION_ENGINE is django.contrib.sessions.backends.db. Every authenticated request reads django_session, and any request that modifies the session writes to it. At high RPS that is real load on the primary database. cached_db or a cache-only backend on Redis removes most of it.
Server and runtime settings to check
Gunicorn
# gunicorn.conf.py
import multiprocessing
workers = multiprocessing.cpu_count() * 2 + 1 # Gunicorn's suggested starting point
worker_class = "gthread"
threads = 4
timeout = 30
graceful_timeout = 30
keepalive = 5 # keep above 2 s when behind a load balancer that reuses connections
max_requests = 2000
max_requests_jitter = 200
accesslog = "-"
In containers, cpu_count() may return the host's core count, not your CPU limit. Set workers explicitly from the container's CPU allocation.
ASGI with Uvicorn
uvicorn myproject.asgi:application --workers 4 --timeout-keep-alive 75
# or under Gunicorn, using the separate uvicorn-worker package:
gunicorn myproject.asgi:application -k uvicorn_worker.UvicornWorker -w 4
The old uvicorn.workers.UvicornWorker class is deprecated in favor of the uvicorn-worker package.
Database connections
Under WSGI, persistent connections are the simple fix:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
# ...
"CONN_MAX_AGE": 60,
"CONN_HEALTH_CHECKS": True,
}
}
Since Django 5.1, PostgreSQL also has a native pool built on psycopg 3 (install psycopg[pool]). Django's docs say CONN_MAX_AGE must be 0 when the pool is enabled, and that under ASGI you should disable persistent connections and use the backend's pooling:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"CONN_MAX_AGE": 0,
"OPTIONS": {
"pool": {"min_size": 4, "max_size": 16, "timeout": 10},
},
}
}
The pool is per process, so total connections = processes × max_size. Use the connection pool size calculator to check the total against max_connections, or put PgBouncer in front if the numbers do not fit.
Static files
Do not let Gunicorn workers serve static files during a test. Each CSS or JS request uses a worker slot that should be running views. Serve STATIC_ROOT from nginx or a CDN (or WhiteNoise with compressed, hashed files and long cache headers). Then leave static assets out of the app test, or test the CDN separately.
Django tuning table
| Setting | Default | What to change | Why |
|---|---|---|---|
Gunicorn workers | 1 | Start at (2 × cores) + 1, then measure | One sync worker is one request at a time |
Gunicorn threads | 1 (sync) | 2 to 8 for I/O-heavy views (gthread) | Threads release the GIL while waiting on DB and HTTP |
Gunicorn timeout | 30 s | Keep; fix slow requests instead | A higher value only delays the WORKER TIMEOUT |
Gunicorn keepalive | 2 s | 5 s or more behind a reusing proxy | Fewer reconnects from nginx or the load balancer |
CONN_MAX_AGE | 0 (close each request) | 60 under WSGI; 0 with the pool or ASGI | Removes per-request connection setup |
OPTIONS["pool"] | off | Enable with explicit max_size under ASGI | Shares connections across threads in a process |
SESSION_ENGINE | db | cached_db or cache on Redis | Removes a session table read and write per request |
PostgreSQL max_connections | 100 | ≥ total pool size + Celery + margin, or PgBouncer | Stops "too many clients" when you scale out |
DEBUG | True in new projects' settings | False | DEBUG stores every SQL query in memory per request |
What to put in the test scenario
- List and detail views with realistic page sizes and IDs. Use list views to catch N+1 queries and detail views across many IDs to avoid a single hot row.
- Search and filter endpoints, especially those that use
icontainson unindexed columns. - Authenticated traffic: log in once per virtual user and reuse the
sessionidcookie. - Writes: form posts and DRF POST/PATCH endpoints, which take row locks and may trigger signals and Celery tasks.
- Not the admin, unless staff traffic is actually heavy.
CSRF in the script
Django's CsrfViewMiddleware rejects unsafe methods without a valid token: Forbidden (403) CSRF verification failed. Request aborted. with a reason such as CSRF cookie not set or CSRF token missing. Each virtual user should:
- GET the login or form page and keep the
csrftokencookie. - POST with the
csrfmiddlewaretokenfield, or send the headerX-CSRFToken: <cookie value>for AJAX and DRF session-auth calls. - On HTTPS, send an
OriginorRefererthat matches the host or an entry inCSRF_TRUSTED_ORIGINS. Missing it givesOrigin checking failedorReferer checking failed.
APIs using token or JWT auth in DRF do not need CSRF; send the Authorization header instead. More detail in authentication load testing.
How to read Django-specific signals
| Symptom | Likely cause | Confirm with |
|---|---|---|
| p95 rises, app CPU low, DB CPU high | N+1 or missing index | pg_stat_statements ordered by total time; calls per request |
| p95 rises, app CPU low, DB idle | Workers blocked on an outbound API or lock | py-spy dump --pid on a worker |
| App CPU at 100% on all cores | Template rendering, serialization, or too few servers | py-spy top; see high CPU under load |
| WORKER TIMEOUT in logs | Requests over 30 s | Gunicorn error log; slow query log |
Connection count in pg_stat_activity at the limit | Too many workers × threads, or no pooling | SELECT count(*) FROM pg_stat_activity |
| Error spike right after ramp-up | Cold connections, cache warm-up | Compare first minute vs steady state |
py-spy attaches to a running worker without restarting it, which makes it the best tool to explain a plateau while the test is still running. If p95 climbs steadily as RPS rises, the pattern in latency increases under load tells you which resource saturated first.
Sync vs async views under load
Switching to ASGI does not automatically raise capacity. An async view that does await Order.objects.aget(pk=pk) still runs the query through a thread, because Django's database layer is synchronous underneath. An async view that calls a sync function without sync_to_async raises SynchronousOnlyOperation: You cannot call this from an async context - use a thread or sync_to_async. Worse, a blocking call such as requests.get() inside an async view stalls every request on that event loop.
Async pays off for views that fan out to several slow services with httpx.AsyncClient, or hold long-lived connections. Load test both deployments with the same scenario and compare p95 at the same RPS before you migrate.
Pre-test checklist
DEBUG = False, production settings module, Django Debug Toolbar and Silk off.- Gunicorn workers set from the container CPU limit; worker class and threads chosen on purpose.
CONN_MAX_AGEor the psycopg pool configured; total connections calculated againstmax_connections.- Key list views checked with
assertNumQueries;pg_stat_statementsreset before the run. - Sessions on cache or
cached_dbif you expect high authenticated traffic. - Static files served by nginx, a CDN or WhiteNoise, not counted in the app test.
- Script gets
csrftokenandsessionidper virtual user and sends a matching Referer on HTTPS. - Celery queues monitored; tasks triggered by test writes are expected and drained.
py-spyinstalled on one app host for live stack dumps.
Run a short ramp in LoadTester's exact-RPS mode with a p95 threshold first. When it auto-stops, the RPS at that point is your current ceiling, and the signals above tell you which setting to change next.
FAQ
Why does my Django load test fail with 'CSRF verification failed'?
Django rejects unsafe requests without a token that matches the csrftoken cookie, and on HTTPS it also checks the Origin or Referer header against the host and CSRF_TRUSTED_ORIGINS. Fetch a page first to get the cookie, send the value as the csrfmiddlewaretoken field or X-CSRFToken header, and send a matching Referer for HTTPS.
How many Gunicorn workers should Django use?
Gunicorn's docs suggest (2 x CPU cores) + 1 as a starting point for sync workers. Treat it as a starting point and measure. If requests spend most of their time waiting on the database or APIs, threads or more workers help; if CPU is already at 100%, more workers only add queueing.
Should I use CONN_MAX_AGE or the psycopg pool?
Under WSGI with sync workers, CONN_MAX_AGE of around 60 seconds removes the per-request connection setup and is simple. Under ASGI, Django's docs say to disable persistent connections and use the backend's pooling instead, which for PostgreSQL is the OPTIONS pool setting with psycopg 3. You cannot combine the pool with a non-zero CONN_MAX_AGE.
Do async views make Django faster under load?
Only if the view awaits real async I/O, such as httpx calls to other services. Async views that call the ORM still run those queries through a thread, and sync views under ASGI pay a context-switch cost. For a typical CRUD app, a well-tuned WSGI setup is often just as fast.
How do I find N+1 queries before a load test?
Use assertNumQueries in tests for your key views, or django-debug-toolbar locally, and look for query counts that grow with page size. Fix them with select_related and prefetch_related. Under load each extra query holds a database connection longer and adds up fast.