How to Load Test a Django App Before Release
Pre-release load testing answers a practical deployment question: will this Django release survive real traffic on your actual production stack.
Problem statement
Pre-release load testing answers a practical deployment question: will this Django release survive real traffic on your actual production stack.
A Django app can look fine in manual staging checks and still fail after release because of worker saturation, slow queries, connection exhaustion, misconfigured static files, or proxy timeouts. These failures often appear only when requests arrive concurrently and repeatedly.
Why Django load testing before release matters
If you want reliable releases, test the release candidate on a production-like staging environment with the same major components you use in production:
- reverse proxy such as Nginx or Caddy
- Gunicorn or Uvicorn
- PostgreSQL
- Redis if used for cache, sessions, or background work
- realistic environment variables and app settings
What can go wrong if you skip pre-release load testing
Common failures include:
- Gunicorn workers timing out under moderate concurrency
- PostgreSQL connections maxing out
- one expensive endpoint blocking the release
- static files accidentally served by Django instead of Nginx
- login flows failing because of session or CSRF handling under load
- a migration making a hot query slower at launch data volume
What this guide will and will not cover
This guide covers a practical process for load testing a Django app before release, using staging, realistic traffic profiles, and basic bottleneck analysis.
It does not cover internet-scale benchmarking or vendor-specific observability platforms.
Quick answer
- Test staging, not
runserver. Use the same reverse proxy, app server, database, and cache pattern as production. - Define pass/fail thresholds before running the test. At minimum: p95 latency, error rate, and throughput targets.
- Simulate critical user flows, not only the homepage.
- Monitor app, proxy, database, and cache together while the test runs.
- Do not release if migrations, worker settings, proxy behavior, or connection limits fail under expected traffic.
Step-by-step solution
1. Verify the staging stack before testing
Use the exact release candidate you plan to deploy.
python manage.py check --deploy
python manage.py showmigrations
# If supported by your Django version:
python manage.py migrate --plan
systemctl status gunicorn
systemctl status nginx
Container-based alternative:
docker compose ps
Verification checks:
- Django system checks complete without critical deployment warnings
- the migration state matches what you expect
- Gunicorn and Nginx are running
- staging is isolated from production services
DEBUGisFalsein stagingALLOWED_HOSTSmatches the staging hostname(s)CSRF_TRUSTED_ORIGINSis set correctly if the staging host or scheme requires itSECURE_PROXY_SSL_HEADERis configured correctly when TLS is terminated at the proxy- static files are actually served by Nginx, Caddy, or object storage if that is how production works
If you find config drift between staging and production, stop and fix that first. Load test results from a non-matching environment are weak release evidence.
Also apply the same rule to secrets and environment configuration as to data: use staging-specific secrets and credentials, not production secrets copied into staging.
2. Seed realistic data
Load tests are only useful if the database size and cache behavior resemble production.
python manage.py loaddata sample-production-like-data.json
Check row counts in PostgreSQL:
psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM auth_user;"
psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM orders_order;"
Use sanitized data only. Do not copy production secrets or personal data into staging.
Verification checks:
- key tables have realistic row counts
- test accounts exist
- critical flows can be exercised manually before automation
3. Define the traffic profile
Before running tools, write down:
- critical user journeys
- target requests per second
- target concurrency
- ramp rate
- test duration
- pass/fail thresholds
A simple release validation profile might look like:
- baseline: 20 concurrent users for 5 minutes
- peak: 50 concurrent users for 10 minutes
- spike: ramp from 10 to 100 users over 2 minutes
- pass threshold: p95 under 800ms, error rate under 1%
This matters because “the site felt fast” is not a release gate.
4. Choose a load testing tool
Use Locust when you need realistic Django user flows with login, sessions, and multiple endpoints. Use k6 when you want scripted scenarios with clear thresholds. Use hey only for single-endpoint smoke benchmarking.
Locust example
from locust import HttpUser, task, between
class DjangoUser(HttpUser):
wait_time = between(1, 3)
@task(3)
def homepage(self):
self.client.get("/")
@task(1)
def dashboard(self):
self.client.get("/dashboard/", name="/dashboard/")
Run it:
locust -f locustfile.py --headless -u 50 -r 5 -t 5m --host=https://staging.example.com
For authenticated flows, log in first and preserve the session. If your login form uses CSRF protection, fetch the login page and submit the CSRF token rather than bypassing it.
Minimal Locust pattern for login with CSRF:
from bs4 import BeautifulSoup
from locust import HttpUser, task, between
class AuthenticatedDjangoUser(HttpUser):
wait_time = between(1, 3)
def on_start(self):
response = self.client.get("/accounts/login/")
soup = BeautifulSoup(response.text, "html.parser")
csrf = soup.find("input", {"name": "csrfmiddlewaretoken"})["value"]
self.client.post(
"/accounts/login/",
data={
"username": "testuser",
"password": "testpass",
"csrfmiddlewaretoken": csrf,
},
headers={"Referer": f"{self.host}/accounts/login/"},
)
@task
def dashboard(self):
self.client.get("/dashboard/")
k6 example
import http from 'k6/http';
import { sleep, check } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 20 },
{ duration: '5m', target: 50 },
{ duration: '2m', target: 0 },
],
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<800'],
},
};
export default function () {
const res = http.get('https://staging.example.com/');
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(1);
}
Run it:
k6 run script.js
Lightweight single-endpoint check
hey -n 1000 -c 20 https://staging.example.com/health/
Use this only to smoke test one endpoint, not to validate the release.
5. Run the test safely
Start small, then increase concurrency gradually.
While the test runs, monitor the host and services:
htop
vmstat 1
ss -s
journalctl -u gunicorn -f
tail -f /var/log/nginx/access.log /var/log/nginx/error.log
Check PostgreSQL activity:
psql "$DATABASE_URL" -c "SELECT state, count(*) FROM pg_stat_activity GROUP BY state;"
What to watch:
- CPU saturation
- memory pressure or swapping
- rising error rates
- request timeouts
- database connection growth
- repeated 499, 502, 504, or other 5xx responses
- Redis saturation if cache or session traffic is involved
Do not point test traffic at production-only dependencies. If your app calls third-party APIs, stub them, disable them, or use safe test credentials and rate limits.
6. Tune common bottlenecks
Gunicorn worker settings
A common issue is too few workers or timeouts that do not match real request behavior.
Example systemd snippet:
[Service]
Environment="DJANGO_SETTINGS_MODULE=config.settings.production"
ExecStart=/srv/app/venv/bin/gunicorn config.wsgi:application \
--bind 127.0.0.1:8000 \
--workers 4 \
--timeout 30 \
--access-logfile - \
--error-logfile -
Restart=on-failure
RestartSec=5
After changes:
systemctl daemon-reload
systemctl restart gunicorn
systemctl status gunicorn
journalctl -u gunicorn -n 50 --no-pager
Retest after each change. Do not tune multiple variables at once if you want useful comparisons.
Rollback note: if a worker change causes instability, restore the previous unit file, reload systemd, restart Gunicorn, and confirm the service starts cleanly before re-running the test.
Nginx proxy alignment
Make sure proxy settings do not create artificial failures.
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 30s;
}
If staging terminates TLS at the proxy, verify Django trusts the forwarded scheme correctly before testing login, redirects, secure cookies, or CSRF behavior.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
Validate and reload safely:
nginx -t
systemctl reload nginx
Rollback note: if reload introduces errors, restore the previous config, validate with nginx -t, and reload again.
Slow queries and connection pressure
If database-heavy endpoints degrade first, inspect the slow query path.
psql "$DATABASE_URL" -c "SELECT count(*) FROM pg_stat_activity;"
For a known slow query, run EXPLAIN ANALYZE in staging before adding indexes or changing ORM behavior. Re-test after any schema or query change.
Do not assume migration rollback is a safe recovery path; some schema changes are not trivially reversible, so test migrations separately before load validation.
7. Record the result and gate the release
Keep a simple decision record for each tested release:
- Date: 2026-04-24
- Commit SHA: abc1234
- Image tag: web-abc1234
- Gunicorn workers: 4
- Nginx config version: 2026-04-24
- Database size note: production-like seed v3
- Test scenarios: baseline, peak, spike
- Result: PASS
- Notes: dashboard endpoint improved after index added
Do not release based on anecdotal testing. Release only if the measured result meets your thresholds.
When to script this process
Once you repeat this before every release, the manual steps become good candidates for automation. Teams usually script staging checks, data seeding, standard Locust or k6 runs, metric capture, and release reports first. That keeps test inputs consistent and makes regressions easier to spot.
Explanation
This process works because it tests the real failure points in a Django deployment chain:
- reverse proxy limits and timeout behavior
- Gunicorn or Uvicorn worker capacity
- database query cost and connection exhaustion
- cache and sessions under concurrency
- application code paths that only break under repeated parallel requests
Testing runserver is not useful for release validation because it does not represent production behavior. Likewise, hitting only / tells you almost nothing about authenticated views, write-heavy endpoints, or pages that run expensive queries.
Locust is usually the better fit when you need session-backed Django behavior and multi-step flows. k6 is strong when you want explicit threshold-based execution in CI. Small tools like hey are useful for a quick benchmark of one route, but they do not replace scenario-based testing.
Edge cases / notes
- Docker vs non-Docker: if production runs in containers, stage the same way. Host-level and container-level CPU or memory limits can change results.
- ASGI and async views: if you use Uvicorn or another ASGI server, include async endpoints in tests and verify upstream timeout alignment.
- Static and media: do not benchmark static asset delivery through Django if production serves them through Nginx, Caddy, or object storage.
- Migrations first: run and validate migrations before the main performance test. A schema change can alter query plans significantly.
- Third-party APIs: avoid generating accidental real traffic or charges during test runs.
- Large uploads and downloads: test them separately if they are business-critical. They distort results for standard page traffic.
- TLS and proxy headers: if staging terminates TLS differently from production, document that difference because it can affect request handling and security assumptions.
- Recovery planning: if a release fails load validation because of schema, proxy, or worker changes, treat rollback as a controlled deployment task, not an assumption. App rollback is usually simpler than schema rollback.
Internal links
If you need the environment setup first, read What Is a Production-Like Django Staging Environment.
For the stack itself, see Deploy Django with Gunicorn and Nginx on Ubuntu and Deploy Django ASGI with Uvicorn and Nginx.
If you are using a different reverse proxy, see Deploy Django with Caddy and Automatic HTTPS.
For validation before release, use Django Deployment Checklist for Production.
FAQ
Should I load test a Django app against staging or production?
Use staging for pre-release validation. It should mirror production closely, but remain isolated from real users and production-only dependencies. Production load testing is a separate operational decision and carries more risk.
What is a reasonable concurrency target for Django load testing before release?
Use expected launch traffic plus headroom. Start with current or forecast peak concurrent users, then add a safety margin. If you do not have historical data, test baseline, peak, and short spike scenarios rather than picking one arbitrary number.
How do I load test authenticated Django views that use sessions and CSRF?
Use a tool that supports multi-step flows, such as Locust or k6. Fetch the login page first, capture the CSRF token, submit the login form, and then reuse the authenticated session or cookies for later requests.
Which metrics should block a release if they degrade under load?
At minimum:
- p95 latency
- error rate
- throughput at target concurrency
- CPU and memory pressure
- database connections and slow query behavior
If one critical endpoint fails under expected traffic, that alone can justify blocking the release.
How often should I repeat load testing in the release process?
Repeat it for meaningful changes to traffic-sensitive code, infrastructure, query behavior, caching, worker settings, or schema. At minimum, run it before important releases and after any fix intended to improve performance.