Framework guide

How to Load Test a Django App

Updated October 27, 2026•11 min read•Django•Python

Written by

Reviewed by the LoadTester editorial team. Review process: see the editorial policy.

Published
2026-10-27
Last reviewed
2026-10-27
Author
Kristian Razum
Diagram of Gunicorn worker processes and threads each holding a PostgreSQL connection, multiplied across servers

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 = 30 and backlog = 2048. Each worker process handles one request at a time. Concurrency equals the number of workers.
  • Gunicorn, gthread workers. With --threads greater 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.

Total database connections equal servers times workers times threads Connections = servers × workers × threads server 1 worker 4 threads worker 4 threads × 5 workers = 20 connections × 6 120 connections PostgreSQL max_connections = 100 FATAL: too many clients Add Celery workers, cron jobs and admin sessions on top of this.
Example: six app servers with 5 Gunicorn workers × 4 threads each can open 120 connections, more than PostgreSQL's default limit of 100.

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

SettingDefaultWhat to changeWhy
Gunicorn workers1Start at (2 × cores) + 1, then measureOne sync worker is one request at a time
Gunicorn threads1 (sync)2 to 8 for I/O-heavy views (gthread)Threads release the GIL while waiting on DB and HTTP
Gunicorn timeout30 sKeep; fix slow requests insteadA higher value only delays the WORKER TIMEOUT
Gunicorn keepalive2 s5 s or more behind a reusing proxyFewer reconnects from nginx or the load balancer
CONN_MAX_AGE0 (close each request)60 under WSGI; 0 with the pool or ASGIRemoves per-request connection setup
OPTIONS["pool"]offEnable with explicit max_size under ASGIShares connections across threads in a process
SESSION_ENGINEdbcached_db or cache on RedisRemoves a session table read and write per request
PostgreSQL max_connections100≥ total pool size + Celery + margin, or PgBouncerStops "too many clients" when you scale out
DEBUGTrue in new projects' settingsFalseDEBUG 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 icontains on unindexed columns.
  • Authenticated traffic: log in once per virtual user and reuse the sessionid cookie.
  • 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:

  1. GET the login or form page and keep the csrftoken cookie.
  2. POST with the csrfmiddlewaretoken field, or send the header X-CSRFToken: <cookie value> for AJAX and DRF session-auth calls.
  3. On HTTPS, send an Origin or Referer that matches the host or an entry in CSRF_TRUSTED_ORIGINS. Missing it gives Origin checking failed or Referer 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

SymptomLikely causeConfirm with
p95 rises, app CPU low, DB CPU highN+1 or missing indexpg_stat_statements ordered by total time; calls per request
p95 rises, app CPU low, DB idleWorkers blocked on an outbound API or lockpy-spy dump --pid on a worker
App CPU at 100% on all coresTemplate rendering, serialization, or too few serverspy-spy top; see high CPU under load
WORKER TIMEOUT in logsRequests over 30 sGunicorn error log; slow query log
Connection count in pg_stat_activity at the limitToo many workers × threads, or no poolingSELECT count(*) FROM pg_stat_activity
Error spike right after ramp-upCold connections, cache warm-upCompare 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

  1. DEBUG = False, production settings module, Django Debug Toolbar and Silk off.
  2. Gunicorn workers set from the container CPU limit; worker class and threads chosen on purpose.
  3. CONN_MAX_AGE or the psycopg pool configured; total connections calculated against max_connections.
  4. Key list views checked with assertNumQueries; pg_stat_statements reset before the run.
  5. Sessions on cache or cached_db if you expect high authenticated traffic.
  6. Static files served by nginx, a CDN or WhiteNoise, not counted in the app test.
  7. Script gets csrftoken and sessionid per virtual user and sends a matching Referer on HTTPS.
  8. Celery queues monitored; tasks triggered by test writes are expected and drained.
  9. py-spy installed 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.

Run this as a real load testFree plan: 10 VUs, 50 RPS, live p95/p99 and error charts, no credit card. Starter $19/mo (250 VUs) or Pro $49/mo (2,000 VUs) with a 7-day free trial when you need more.
Start freeCompare plans