Operations
#django
#locust

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

  1. Test staging, not runserver. Use the same reverse proxy, app server, database, and cache pattern as production.
  2. Define pass/fail thresholds before running the test. At minimum: p95 latency, error rate, and throughput targets.
  3. Simulate critical user flows, not only the homepage.
  4. Monitor app, proxy, database, and cache together while the test runs.
  5. 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
  • DEBUG is False in staging
  • ALLOWED_HOSTS matches the staging hostname(s)
  • CSRF_TRUSTED_ORIGINS is set correctly if the staging host or scheme requires it
  • SECURE_PROXY_SSL_HEADER is 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.

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.

2026 · django-deployment.com - Django Deployment knowledge base