Django at Scale: Security Hardening, Performance, Observability, and Operations
State of practice as of 2026-09. The seed sources are Django’s own deployment checklist [1], system check reference [2], settings reference [3] and security topic guide [4]; every default, version and “added in” claim below was read off the cited page rather than recalled. The version line described is Django 6.1, the current feature release, alongside Django 5.2, the current long-term support release [10]. Supporting claims come from the third-party projects Django leaves you to choose (axes, allauth, guardian, whitenoise, gunicorn, uvicorn, granian, psycopg, PgBouncer, OpenTelemetry, Sentry). Inline [n] keys to sources. Code is illustrative: settings fragments are compile-checked, not run.
Scope note: application structure, the service layer, ORM modelling, migrations authoring, the API layer and multi-tenancy belong to the enterprise Django architecture doc. This document owns what happens after the code is written. Container build and runtime practice lives in docker-assembly-guide.md; cache-shape decisions at the data layer live in database-design-guide.md.
1. The deploy gate: check --deploy and the settings it audits
manage.py check --deploy “activates some additional checks that are only relevant in a deployment setting” and takes --fail-level {CRITICAL,ERROR,WARNING,INFO,DEBUG}, which “specifies the message level that will cause the command to exit with a non-zero status. Default is ERROR” [37]. That default is the trap: every deployment check is a warning, so the command exits 0 with a page of security.W0xx output unless you run --fail-level WARNING [2][37]. Point it at production settings with --settings or run it on the deployment itself [37].
The deploy-only list is security.W001 through security.W022 plus security.E023, security.E024 and security.W025, with W007 and W017 removed in earlier Django versions [2]. The CSP checks security.E026 and security.W027 sit in the separate always-run group, “the following checks verify that your security-related settings are correctly configured”, so they fire without --deploy [2]. Silence the ones your load balancer already handles with SILENCED_SYSTEM_CHECKS rather than by lowering --fail-level [2].
The settings those checks audit, with the documented defaults [3]:
| Setting | Default [3] | Production value | Check |
|---|---|---|---|
DEBUG | n/a | False; “You must never enable debug in production” [1] | W018 |
ALLOWED_HOSTS | n/a | explicit hostnames; “must not be empty in deployment” [2] | W020 |
SECRET_KEY | n/a | 50+ chars, 5+ unique, no django-insecure- prefix [2] | W009 |
SECRET_KEY_FALLBACKS | [] | previous keys during rotation, same strength bar | W025 |
SECURE_SSL_REDIRECT | False | True, or redirect at the proxy | W008 |
SECURE_HSTS_SECONDS | 0 | non-zero once you are certain | W004 |
SECURE_HSTS_INCLUDE_SUBDOMAINS | False | True only if every subdomain is HTTPS | W005 |
SECURE_HSTS_PRELOAD | False | True only if you intend to submit | W021 |
SESSION_COOKIE_SECURE | False | True | W010/W011/W012 |
CSRF_COOKIE_SECURE | False | True | W016 |
SECURE_CONTENT_TYPE_NOSNIFF | True | leave alone | W006 |
SECURE_REFERRER_POLICY | 'same-origin' | leave alone or tighten | W022 |
SECURE_CROSS_ORIGIN_OPENER_POLICY | 'same-origin' | leave alone | n/a |
X_FRAME_OPTIONS | 'DENY' | leave alone | W019 |
SECURE_PROXY_SSL_HEADER | None | set only if a trusted proxy terminates TLS | n/a |
Two defaults are already safe and get changed by accident more often than they get set: SECURE_CONTENT_TYPE_NOSNIFF is True and X_FRAME_OPTIONS is 'DENY' [3]. SECURE_HSTS_INCLUDE_SUBDOMAINS carries an explicit warning that setting it incorrectly is irreversible for the lifetime of SECURE_HSTS_SECONDS [3], so ramp HSTS: minutes, then days, then a year, and only then preload.
CSRF_COOKIE_SAMESITE and SESSION_COOKIE_SAMESITE both already default to 'Lax' [3]; there is no deployment check for them, and raising either to 'Strict' breaks top-level navigation back into your site, so change them deliberately.
Host header. Django validates Host against ALLOWED_HOSTS inside HttpRequest.get_host(), and “this validation only applies via get_host(); if your code accesses the Host header directly from request.META you are bypassing this security protection” [4]. Django requires ALLOWED_HOSTS explicitly rather than trusting the web server, because “even seemingly-secure web server configurations are susceptible to fake Host headers” [4]. Wildcards are allowed, but the checklist attaches a condition: “If you use a wildcard, you must perform your own validation of the Host HTTP header, or otherwise ensure that you aren’t vulnerable to this category of attacks” [1]. It also recommends a catch-all server block that closes the connection for unknown hosts [1].
Key rotation. SECRET_KEY_FALLBACKS defaults to []; the rotation procedure is to set a new SECRET_KEY, move the previous value to the beginning of SECRET_KEY_FALLBACKS, then remove old values once they are no longer needed [3], and the checklist adds that old keys should be removed “in a timely manner” [1]. It backs “many of Django’s security-critical features” [2], so a rotation that skips the fallback window invalidates every signed value at once [3].
# settings/production.py (illustrative: needs django-environ installed)import environ
env = environ.Env()
SECRET_KEY = env("SECRET_KEY")SECRET_KEY_FALLBACKS = env.list("SECRET_KEY_FALLBACKS", default=[])DEBUG = FalseALLOWED_HOSTS = env.list("ALLOWED_HOSTS")CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
SECURE_SSL_REDIRECT = TrueSECURE_HSTS_SECONDS = 31536000SECURE_HSTS_INCLUDE_SUBDOMAINS = TrueSESSION_COOKIE_SECURE = TrueCSRF_COOKIE_SECURE = True
# DATABASE_URL looks like postgres://localhost/app in development.DATABASES = {"default": env.db_url("DATABASE_URL")}CSRF. CSRF_TRUSTED_ORIGINS defaults to [] [3]; CsrfViewMiddleware verifies the Origin header against the current host and that setting, and for HTTPS requests without an Origin header it “performs strict referer checking” [5]. The documented limitation is structural: “Subdomains within a site will be able to set cookies on the client for the whole domain. By setting the cookie and using a corresponding token, subdomains will be able to circumvent the CSRF protection. The only way to avoid this is to ensure that subdomains are controlled by trusted users” [5]. If you hand a subdomain to a third party, CSRF protection stops being a control you own [5]. CSRF_USE_SESSIONS moves the token into the session for sites that have one [5].
Content Security Policy. Django 6.0 added built-in CSP: “Built-in support for the Content Security Policy (CSP) standard is now available, making it easier to protect web applications against content injection attacks such as cross-site scripting (XSS)” [6]. The surface is SECURE_CSP and SECURE_CSP_REPORT_ONLY (both defaulting to {} [3]), django.middleware.csp.ContentSecurityPolicyMiddleware, and the csp() context processor for nonces [6]. Django 6.1 added the csp_nonce_attr template tag, applied CSP nonces to admin and built-in templates when the csp() context processor is configured, and added security.W027, which “warns when ContentSecurityPolicyMiddleware is enabled with CSP.NONCE in a CSP policy but django.template.context_processors.csp is not configured” [7]. security.E026 fires when a CSP setting is not a dictionary [2].
# Illustrative: Django 6.0+ built-in CSP.from django.utils.csp import CSP
SECURE_CSP = { "default-src": [CSP.SELF], "script-src": [CSP.SELF, CSP.NONCE], "img-src": [CSP.SELF, "https:"],}Decision rule: on Django 6.0 or later use the built-in settings; django-csp remains the option for older lines and offers CONTENT_SECURITY_POLICY / CONTENT_SECURITY_POLICY_REPORT_ONLY dictionaries with DIRECTIVES, EXCLUDE_URL_PREFIXES and a REPORT_PERCENTAGE throttle, via csp.middleware.CSPMiddleware [17]. Do not run both, since each sets the same response header [6][17]. Django’s own guidance warns against EXCLUDE_URL_PREFIXES-style carve-outs: “a vulnerability on an unprotected page (e.g., one allowing arbitrary script injection) may be leveraged to attack protected pages”, so “excluding any route can significantly weaken the site’s overall CSP protection” [4]. Ship report-only first (SECURE_CSP_REPORT_ONLY [6], or django-csp’s report-only policy, “used to test the policy without breaking the site” [17]), read the reports, then enforce.
2. Security hardening beyond the checklist
Password hashers. The default PASSWORD_HASHERS list starts with PBKDF2PasswordHasher [13], and the default iteration count has been raised on every recent release: 720,000 to 870,000 in 5.1 [8], 870,000 to 1,000,000 in 5.2 [9], and 1,200,000 to 1,500,000 in 6.1 [7]. Django’s own text says “The Password Hashing Competition panel, however, recommends immediate use of Argon2 rather than the other algorithms supported by Django” [13]. Switching is one install and a list reorder, and existing hashes upgrade on next login: “When users log in, if their passwords are stored with anything other than the preferred algorithm, Django will automatically upgrade the algorithm to the preferred one” [13].
# python -m pip install django[argon2]PASSWORD_HASHERS = [ "django.contrib.auth.hashers.Argon2PasswordHasher", "django.contrib.auth.hashers.PBKDF2PasswordHasher", "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", "django.contrib.auth.hashers.ScryptPasswordHasher",]Brute force. Django states plainly that it “does not throttle requests to authenticate users” and that you should deploy a plugin or web server module to throttle against brute-force attacks [4]. django-axes fills that gap by tracking failed authentication attempts and locking the offender out [14]. Its installation page puts axes.backends.AxesStandaloneBackend “to the top of AUTHENTICATION_BACKENDS” and axes.middleware.AxesMiddleware last in MIDDLEWARE, noting that the middleware “only formats user lockout messages and renders Axes lockout responses on failed user authentication attempts from login views” [14]. What gets counted together is AXES_LOCKOUT_PARAMETERS, a list over the parameters ip_address, username and user_agent. A bare entry is tracked on its own, so ["username", "ip_address"] locks out on either; a nested entry is a combination, so [["username", "ip_address"]] locks out only on the pair, and [["ip_address", "user_agent"], "username"] mixes the two forms [14]. Two operational details from the same page: AXES_HTTP_RESPONSE_CODE “default has been changed from 403 (Forbidden) to 429 (Too Many Requests)”, and setting AXES_ENABLED = False “disables the Axes middleware, authentication backend and signal receivers” for tests [14].
Identity. django-allauth covers local accounts, social/OAuth2, SAML, OIDC, MFA and headless API flows in one dependency, split across allauth.account, allauth.socialaccount, allauth.mfa, allauth.headless and allauth.idp, and requires allauth.account.middleware.AccountMiddleware [15]. Those apps share one account layer, so adding SSO or MFA later is a settings change rather than a migration of the user model [15].
Whole-site authentication. Django 5.1 added LoginRequiredMiddleware, which “redirects all unauthenticated requests to a login page”, with login_not_required() to opt individual views out [8]. Because it inverts the default, the failure mode changes from “forgot to protect a view” to “forgot to exempt one”, and the second is visible on the first request [8].
Object-level permissions. Django stops at the model: “Django’s permission framework has a foundation for object permissions, though there is no implementation for it in the core. That means that checking for object permissions will always return False or an empty list”, and it is an authentication backend that “will receive the keyword parameters obj and user_obj for each object related authorization method” [43]. django-guardian supplies “an implementation of object permissions for Django providing an extra authentication backend” and supports Python 3.9+ and Django 4.2+ [16]. rules is the other shape: “a tiny but powerful app providing object-level permissions to Django, without requiring a database”, wired in as rules.permissions.ObjectPermissionBackend so that has_perm() with an object argument evaluates a predicate instead of returning False [42]. Guardian fits grants that are data (one user shares one document); rules fits grants that are a function of the object (“no need to mess around with a database to figure out whether John really wrote that book” [42]).
Uploads and media. The concrete exploit is in Django’s own docs: “an HTML file can be uploaded as an image if that file contains a valid PNG header followed by malicious HTML. This file will pass verification of the library that Django uses for ImageField image processing (Pillow)” [4]. The mitigation is domain isolation, and the docs are specific that a subdomain is not enough: serve uploads from a distinct top-level or second-level domain such as usercontent-example.com, because “it’s not sufficient to serve content from a subdomain like usercontent.example.com” [4]. Also restrict allowable extensions, disable handlers like mod_php that execute static files as code, and cap request body size in the web server: “Form submissions containing files are not limited by DATA_UPLOAD_MAX_MEMORY_SIZE”, and under ASGI “the entire request may be spooled to disk before any file size validation is performed” [4]. For reference, DATA_UPLOAD_MAX_MEMORY_SIZE and FILE_UPLOAD_MAX_MEMORY_SIZE both default to 2621440 (2.5 MB) and DATA_UPLOAD_MAX_NUMBER_FIELDS to 1000 [3]. The checklist puts it more bluntly: “Media files are uploaded by your users. They’re untrusted! Make sure your web server never attempts to interpret them” [1].
Signed cookies. Django 6.1 changed the default of SIGNED_COOKIE_LEGACY_SALT_FALLBACK to False and deprecated the setting: “Signed cookies now use an unambiguous salt derivation by default. Set SIGNED_COOKIE_LEGACY_SALT_FALLBACK to True to continue accepting legacy signed cookies” [7]. Flip it to True for one deploy across the upgrade, then remove it before it is dropped [7].
Tracking advisories. Security support covers the main branch, “the two most recent Django release series”, and LTS releases for their specified period [11]. Roughly one week before disclosure the team notifies django-announce “of the date and approximate time of the upcoming security release, as well as the severity of the issues” [11], so subscribing to that list buys a week of scheduling notice. The disclosed-issue archive carries a CVE per issue with affected versions and per-version patches [12]; the 2026-08-04 batch (CVE-2026-15307, CVE-2026-15337, CVE-2026-15830, CVE-2026-15920) shipped patches for 6.1, 6.0 and 5.2 [12], which is exactly the “two most recent Django release series” plus the LTS window [11]. Anything older receives no patch [11].
3. Performance in production
3.1 Caching layers
Django’s default cache is LocMemCache, and the docs are blunt: “Each process will have its own private cache instance, which means no cross-process caching is possible … it’s probably not a good choice for production environments” [18]. With four worker processes that is four caches: a value one worker writes is invisible to the other three [18]. Move to django.core.cache.backends.redis.RedisCache, which takes a single URL or a list where the first entry is the leader and the rest are read replicas, and wants redis-py installed with hiredis-py recommended [18].
| Layer | Mechanism | Reach for it when | Source |
|---|---|---|---|
| Per-site | UpdateCacheMiddleware first and FetchFromCacheMiddleware last in MIDDLEWARE, plus CACHE_MIDDLEWARE_SECONDS / _KEY_PREFIX / _ALIAS | the whole site is anonymous and mostly static; caches GET and HEAD responses with status 200 | [18] |
| Per-view | @cache_page(60 * 15), optionally in the URLconf to decouple view from policy | a handful of expensive read-only endpoints | [18] |
| Fragment | {% cache 500 sidebar request.user.username %} | one costly region of an otherwise personalised page | [18] |
| Low-level | cache.get_or_set, get_many / set_many, touch, incr | computed values with your own invalidation | [18] |
The correctness hazard is Vary. CacheMiddleware “varies on Authorization automatically” [18], but personalisation carried in a session cookie is not covered unless you say so with @vary_on_cookie or patch_vary_headers [18]. Rule: per-site caching is for pages that are identical for every anonymous visitor, and everything personalised gets fragment or low-level caching keyed on the varying value, as in the documented {% cache 500 sidebar request.user.username %} form [18].
Sessions are a cache decision too. SESSION_ENGINE defaults to 'django.contrib.sessions.backends.db' with SESSION_COOKIE_AGE of 1209600 seconds (2 weeks) [3], which means a database write per request for logged-in users. The checklist recommends cached sessions for performance, and regular clearing of old sessions if you stay on the database backend [1]; clearsessions “can be run as a cron job” [37].
3.2 Database connections: three strategies, pick exactly one
CONN_MAX_AGE defaults to 0 and CONN_HEALTH_CHECKS to False [3], so out of the box every request opens and closes a PostgreSQL connection. The checklist notes persistent connections give “a nice speed-up” when connection setup is a significant share of request time [1].
Django 5.1 added a native pool: “Django 5.1 also introduces connection pool support for PostgreSQL. As the time to establish a new connection can be relatively long, keeping connections open can reduce latency”, configured with "pool": True or a dict passed to psycopg’s ConnectionPool, and requiring psycopg[pool] [8][19]. Django 5.2 extended pooling to Oracle [9]. The pool is per alias per process: “Django maintains a separate pool for each database alias in each process” [19].
The constraint that bites: “The CONN_MAX_AGE setting must be 0 when connection pooling is enabled. Configure connection lifetime with the max_lifetime pool option instead” [19]. And unless you supply a check callback, CONN_HEALTH_CHECKS decides whether connections are validated on checkout from the pool [19].
| Strategy | Config | Connections to Postgres | Costs |
|---|---|---|---|
| Per-request connect | CONN_MAX_AGE = 0 (default [3]) | one per in-flight request | connection setup on every request [1] |
| Persistent connections | CONN_MAX_AGE = N, CONN_HEALTH_CHECKS = True | one per worker process, held | idle connections scale with workers, not with load |
| Native psycopg pool | OPTIONS = {"pool": {...}}, CONN_MAX_AGE = 0 [19] | bounded per process per alias [19] | needs psycopg[pool]; still per process |
| External pooler | PgBouncer in front | bounded across the whole fleet [21] | mode-dependent feature loss (below) |
psycopg’s ConnectionPool defaults are min_size=4, max_size=None, timeout=30.0, max_lifetime=3600.0 (1 hour), max_idle=600.0, reconnect_timeout=300.0 and num_workers=3 [20]. Note the multiplication: min_size=4 per alias per process, across N worker processes across M containers, is 4NM connections held open before any traffic arrives [20]. Size min_size from the floor you actually want rather than from the default [20].
PgBouncer. The modes are documented as session pooling (“a server connection will be assigned to it for the whole duration it stays connected”), transaction pooling (“a server connection is assigned to a client only during a transaction”), and statement pooling, “transaction pooling with a twist: multi-statement transactions are disallowed” [21]. Transaction pooling is the mode that actually multiplexes connections, and it is also the mode with a published incompatibility list [21]. PgBouncer lists as unsupported in transaction pooling: SET/RESET, LISTEN, WITH HOLD CURSOR, protocol-level prepared plans (requires configuration), PREPARE/DEALLOCATE, PRESERVE/DELETE ROWS temporary tables, LOAD, and session-level advisory locks [21].
Django names the concrete consequence: “Using a connection pooler in transaction pooling mode (e.g. PgBouncer) requires disabling server-side cursors for that connection”, because server-side cursors are local to a connection and a later transaction may land on a different one [19]. Three fixes are documented [19]: “disable server-side cursors for a connection in DATABASES by setting DISABLE_SERVER_SIDE_CURSORS to True” (an uppercase per-alias key of the DATABASES entry, not an OPTIONS key); set up a second connection for the queries that need cursors, which “needs to either be directly to the database or to a connection pooler in session pooling mode”; or wrap each server-side-cursor QuerySet in an atomic() block, “because it disables autocommit for the duration of the transaction”.
# Illustrative: native psycopg pool, mutually exclusive with CONN_MAX_AGE.DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": "app", "HOST": "localhost", "CONN_MAX_AGE": 0, # required when "pool" is set "CONN_HEALTH_CHECKS": True, "OPTIONS": { "pool": {"min_size": 2, "max_size": 8, "max_lifetime": 1800}, }, },}
# Illustrative: behind PgBouncer in transaction pooling mode, drop the# "pool" option above and set this per-alias key instead.DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = TrueDecision rule: because the native pool is per process per alias [19], one process pool per container is enough until connection count at the database becomes the limit; once many containers each hold a pool, move the bound to PgBouncer and set the in-process strategy back to per-request or a very small pool. Never stack a large CONN_MAX_AGE under a transaction-mode pooler: Django already requires CONN_MAX_AGE = 0 alongside its own pool [19], and transaction mode drops the session state a long-lived connection assumes [21].
3.3 Read replicas
Django ships a PrimaryReplicaRouter example with db_for_read returning a random replica, db_for_write returning "primary", and allow_relation accepting pairs from the pool, wired through DATABASE_ROUTERS [22]. The docs then disown it: the configuration “is also flawed: it doesn’t provide any solution for handling replication lag … It also doesn’t consider the interaction of transactions with the database utilization strategy” [22]. Because the shipped router has no answer for replication lag [22], treat replica routing as a per-view opt-in for reports and dashboards rather than a global default, and never read back from a replica in the request that wrote.
3.4 Query budgets in CI
assertNumQueries(num, func, *args, **kwargs) “asserts that when func is called with *args and **kwargs that num database queries are executed”, and also works as a context manager [36]. Because it asserts a count rather than a duration, it turns an N+1 regression into a deterministic test failure [36].
# Illustrative: a query budget test (needs Django installed to run).from django.test import TestCasefrom django.urls import reverse
class DashboardQueryBudget(TestCase): def test_dashboard_stays_within_budget(self): url = reverse("dashboard") with self.assertNumQueries(6): self.client.get(url)Django 6.1 added a second lever, fetch modes [7]. FETCH_PEERS “fetches a missing field for all instances that came from the same QuerySet … It can reduce most cases of the ‘N+1 queries problem’ to two queries without any work to maintain a list of fields to prefetch”, and FETCH_RAISE raises FieldFetchBlocked instead of issuing the query [7]. FETCH_RAISE is the stricter budget of the two: it fails on the specific unfetched field access rather than on an aggregate count [7].
3.5 Static files
ManifestStaticFilesStorage appends the MD5 hash of content to filenames, rewrites @import, url() and source-map references, and stores the mapping in staticfiles.json at collectstatic time; it requires DEBUG = False and a completed collectstatic, raises ValueError for files missing from the manifest unless manifest_strict is disabled, and gives up after max_post_process_passes, which “defaults to 5” [23]. The documented warning is that it “typically shouldn’t be used when running tests as collectstatic isn’t run as part of the normal test setup” [23].
WhiteNoise’s CompressedManifestStaticFilesStorage adds gzip and Brotli, and its middleware “should be placed directly after the Django SecurityMiddleware (if you are using it) and before all other middleware” [24]. It “sends appropriate cache headers with your static content”, which is what lets a CDN serve repeat requests without touching the application [24]. It is explicitly “not suitable for serving user-uploaded ‘media’ files” [24], which lines up with the separate-domain rule in section 2.
3.6 Application server and worker sizing
Django’s ASGI deployment page names Daphne, Granian, Hypercorn and Uvicorn as ASGI servers, and notes that “Django’s default ASGI handler will run all your code in a synchronous thread” unless you write your own async handler, in which case “do not call blocking synchronous functions or libraries in any async code” [29].
| Server | Model | Reach for it when | Source |
|---|---|---|---|
gunicorn sync | pre-fork, one request per worker, no keep-alive, “requires a buffering proxy (nginx, HAProxy) for production” | plain WSGI Django behind a proxy | [25] |
gunicorn gthread | thread pool per worker, supports keep-alive | mixed workloads, moderate concurrency | [25] |
gunicorn gevent | greenlets, thousands of concurrent connections; “may require patches for some libraries (e.g., psycogreen for Psycopg)” | I/O-bound, websockets, long polling | [25] |
| uvicorn | spawn, not pre-fork, so the multiprocess manager works on Windows | ASGI Django; nginx in front “may not be necessary, but is recommended for additional resilience” | [27] |
| granian | Rust (Hyper + Tokio), serves ASGI/3, RSGI and WSGI, HTTP/1 and HTTP/2, HTTPS, mTLS, websockets | one dependency for both ASGI and WSGI, or HTTP/2 at the app server | [28] |
Sizing. Gunicorn’s own guidance is workers = (2 x CPU cores) + 1 as a starting point, with the caveat that workers are not clients and that gunicorn typically needs only 4 to 12 workers to handle heavy traffic, since too many “waste resources and can reduce throughput” [25]. Granian defaults --workers to 1 and advises that “matching the amount of CPU cores for the workers is generally the best starting point; on containerized environments like docker or k8s is best to have 1 worker per container though and scale your containers using the relevant orchestrator” [28]. In containers the second rule dominates, because the orchestrator is already the thing scaling you [28].
Timeouts, from gunicorn’s generated settings reference [26]: timeout defaults to 30 and kills workers silent that long; graceful_timeout defaults to 30; keepalive defaults to 2; max_requests and max_requests_jitter both default to 0, so periodic worker recycling is opt-in; worker_class defaults to 'sync'; forwarded_allow_ips defaults to '127.0.0.1,::1'; and http_protocols defaults to 'h1', so HTTP/2 is opt-in [26]. Set max_requests with non-zero max_requests_jitter to bound leak growth without restarting every worker at once [26]. Behind a proxy, uvicorn reads X-Forwarded-For and X-Forwarded-Proto, but “as anyone can set these headers you must configure which ‘clients’ you will trust to have set them correctly”, which is what --forwarded-allow-ips does [27]; gunicorn’s equivalent trust list is forwarded_allow_ips [26]. Getting that trust list wrong while SECURE_PROXY_SSL_HEADER is set lets a client decide the answer to request.is_secure() [3][27].
A note on the gunicorn plus uvicorn combination: uvicorn’s docs now carry “the uvicorn.workers module is deprecated and will be removed in a future release. You should use the uvicorn-worker package instead” [27].
4. Observability
Logging. The built-in loggers are worth wiring individually rather than catching django at the root [30]:
| Logger | What it emits | Level |
|---|---|---|
django.request | request handling; 5XX as ERROR, 4XX as WARNING, with status_code and request in the record | ERROR / WARNING |
django.security.* | SuspiciousOperation and other security errors; sub-loggers include django.security.DisallowedHost and django.security.csrf | WARNING, ERROR when it reaches the WSGI handler |
django.db.backends | every application-level SQL statement, with duration, sql, params, alias | DEBUG |
django.db.backends.schema | SQL run by migrations (not by RunPython) | DEBUG |
django.contrib.sessions | non-fatal cached_db session errors, with traceback | ERROR |
django.dispatch | signal receiver failures, with receiver and err | ERROR |
Two consequences. First, “requests logged to django.security aren’t logged to django.request” and requests resulting in a 400 go only to django.security [30], so a handler attached to django.request alone silently drops host-header and CSRF attacks. Second, do not plan on django.db.backends for production slow-query logging: “For performance reasons, SQL logging is only enabled when settings.DEBUG is set to True, regardless of the logging level or handlers installed” [30]. Production slow queries belong to the database’s own slow-query log, or to an agent that instruments the driver the way Sentry’s Django integration instruments database queries [34].
For error email, AdminEmailHandler sends to ADMINS and the default configuration gates it behind the RequireDebugFalse filter [30]; ADMINS receive 500s and MANAGERS receive 404s [1]. The checklist’s own verdict is the one to follow: “Error reporting by email doesn’t scale very well … Consider using an error monitoring system such as Sentry before your inbox is flooded by reports” [1]. CallbackFilter is the documented escape hatch for dropping noisy records such as UnreadablePostError [30].
Tracing. OpenTelemetry’s zero-code path is opentelemetry-bootstrap -a install followed by running under opentelemetry-instrument, with Django among the auto-instrumented frameworks and configuration via OTEL_SERVICE_NAME, OTEL_TRACES_EXPORTER and OTEL_EXPORTER_OTLP_TRACES_ENDPOINT [31]. The programmatic form is DjangoInstrumentor().instrument(), with request_hook and response_hook callbacks and OTEL_PYTHON_DJANGO_TRACED_REQUEST_ATTRS for pulling request attributes onto the span [32]. One ordering detail that costs an afternoon: the instrumentation “creates the span before Django middlewares run”, so request.user is not available in request_hook and must be read in response_hook [32].
The pre-fork trap is the important one. “The BatchSpanProcessor is not fork-safe and doesn’t work well with application servers (Gunicorn, uWSGI) which are based on the pre-fork web server model”, because “during the fork, the child process inherits the lock which is held by the parent process and deadlock occurs” [33]. Initialise tracing inside gunicorn’s post_fork hook rather than at import time [33].
# gunicorn.conf.py (illustrative: needs the OTel SDK installed)from opentelemetry import tracefrom opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporterfrom opentelemetry.sdk.resources import Resourcefrom opentelemetry.sdk.trace import TracerProviderfrom opentelemetry.sdk.trace.export import BatchSpanProcessor
def post_fork(server, worker): server.log.info("Worker spawned (pid: %s)", worker.pid) resource = Resource.create(attributes={"service.name": "api-service"}) trace.set_tracer_provider(TracerProvider(resource=resource)) exporter = OTLPSpanExporter(endpoint="http://localhost:4317") trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(exporter))Errors and spans from Sentry. The Django integration instruments the middleware stack, signals, database queries, Redis commands and access to Django caches, and lets you name transactions by URL pattern or by view function via transaction_style [34]. send_default_pii controls whether user data and request headers are attached, so it is the one flag that decides whether your error store holds personal data [34].
Metrics that matter. The four golden signals are latency (“the time it takes to service a request”), traffic (“a measure of how much demand is being placed on your system”), errors (“the rate of requests that fail, either explicitly (e.g., HTTP 500s), implicitly … or by policy”), and saturation (“how ‘full’ your service is”) [35]. Two refinements transfer directly to Django. “It’s important to distinguish between the latency of successful requests and the latency of failed requests” [35]: a fast 500 flatters a p95 that a slow 200 would fail. And “many systems degrade in performance before they achieve 100% utilization, so having a utilization target is essential” [35], which for Django means alerting on connection pool and worker saturation well before they hit their ceiling, since both of those are the resources that hit it first.
5. Operations
Deployment order. Three commands, each closing a different failure. migrate --check “makes migrate exit with a non-zero status when unapplied migrations are detected” and makemigrations --check “makes makemigrations exit with a non-zero status when model changes without migrations are detected” [37]; the second belongs in CI so a model change without a migration never reaches a deploy. sqlmigrate “takes migration names and returns the SQL they would run … without actually running the migration”, which is how you get a reviewable diff before a schema change lands [40]. collectstatic must run before traffic reaches code that renders hashed asset URLs, because ManifestStaticFilesStorage raises ValueError for anything missing from staticfiles.json [23].
Background work. Django 6.0 added a built-in Tasks framework for running code outside the HTTP request and response cycle, configured with the TASKS setting and used via the @task decorator and enqueue() [6]. Read the boundary carefully before planning around it. Of the framework: “It does not provide a worker mechanism to run Tasks. The actual execution must be handled by infrastructure outside Django, such as a separate process or service.” Of the backends: “Django includes backends suitable for development and testing only. Production systems should rely on backends that supply a worker process and a durable queue implementation” [39]. The shipped backends are ImmediateBackend and DummyBackend [39]. What 6.0 standardised is the interface; the runtime is still yours to choose and operate [39].
Feature flags. “Waffle is feature flipper for Django. You can define the conditions for which a flag should be active, and use it in a number of ways” [41]. Paired with the deployment order above, a flag is what lets new code ship dark and be switched on only after the migration has landed [41].
Configuration and secrets. The checklist’s own example reads SECRET_KEY from the environment or a file [1], and says database passwords “are very sensitive” and should be protected “exactly like SECRET_KEY” [1]. Rotation is the SECRET_KEY_FALLBACKS procedure from section 1 [3].
Backups. The checklist does not hedge: “If you haven’t set up backups for your database, do it right now!” [1], and it separately tells you to check your backup strategy for user-uploaded files [1].
Containers. Base image, layer and runtime practice for Django containers is covered in docker-assembly-guide.md and not repeated here. The Django-specific hooks are: run check --deploy --fail-level WARNING as a build or start-time gate [2][37], run collectstatic at build time so the image is self-contained [23], and size workers per container rather than per host [28].
Upgrades. The support window is published: Django 5.2 is the current LTS, with extended support to April 2028; 6.0’s mainstream support ended 2026-08-04 with extended support to April 2027; and 6.1 has mainstream support to April 2027 and extended support to December 2027 [10]. The roadmap table lists 6.2 LTS with a release date of April 2027 and extended support to April 2030, and notes that “Django 6.2 is the final release under the previous versioning and support policy” [10]. From Django 2028 the scheme changes: “Beginning with Django 2028, feature releases will use the version format YYYY and will be released every January”, and “starting with Django 2028, every feature release receives the same three-year support period” [10]. Django 6.1 already renamed the deprecation warning classes to match, RemovedInDjango70Warning becoming RemovedInDjango2028Warning [7].
The upgrade procedure is explicit: go one feature release at a time using the latest patch of each, and “the same incremental upgrade approach is recommended when upgrading from one LTS to the next” [38]. Before upgrading, “resolve any deprecation warnings raised by your project while using your current version of Django” [38]. The mechanism is python -Wa manage.py test, or PYTHONWARNINGS=always pytest tests --capture=no [38]. Run that in CI and fail the build on RemovedInDjango2028Warning, since the warning classes are named for the calendar version in which the features they mark are removed [7]; a green build full of warnings is a scheduled breakage [38]. Clear your cache after upgrading to avoid pickle compatibility problems with cached objects [38].
Python floors move with Django: 6.0 and 6.1 both support Python 3.12, 3.13 and 3.14 [6][7], and “the Django 5.2.x series is the last to support Python 3.10 and 3.11” [6].
Agreed vs folklore (compressed)
- Agreed.
check --deployis the baseline and is designed to be run against production settings or in CI [37]. HTTPS-only cookies, HSTS, explicitALLOWED_HOSTSare settings with documented defaults that are wrong for production [3]. Argon2 is recommended over the Django default by the body Django cites [13]. Django deliberately ships no login throttling and no task worker, and says so [4][39]. Pooling andCONN_MAX_AGEare mutually exclusive by documentation, not by convention [19]. - “
DEBUG = Falseis enough”: folklore. It clears exactly one check,security.W018[2]. It leavesSECURE_SSL_REDIRECTatFalse,SECURE_HSTS_SECONDSat0,SESSION_COOKIE_SECUREatFalseandCSRF_COOKIE_SECUREatFalse[3], all of which the same command reports. It also turns off SQL logging, sincedjango.db.backendsonly emits whenDEBUGisTrue[30], so the setting that hardens the app is also the one that removes your query visibility. - “PgBouncer just works”: folklore in transaction mode. The unsupported list is published:
SET/RESET,LISTEN,WITH HOLD CURSOR, protocol-level prepared plans,PREPARE/DEALLOCATE, session-level advisory locks and more [21], and Django separately requiresDISABLE_SERVER_SIDE_CURSORSon theDATABASESentry for that mode [19]. Session pooling keeps the features and loses most of the multiplexing, so the choice is a trade rather than a free win [21]. - “Cache everything”: folklore. Per-site caching only stores GET and HEAD responses with status 200 [18], and correctness depends on
Vary;CacheMiddlewarevaries onAuthorizationautomatically but not on your session cookie unless you ask [18]. The defaultLocMemCacheis per process, so “we added caching” with the default backend is N private caches, not one [18]. - “gunicorn sync workers cannot scale”: overstated. Sync is the documented default for “CPU-bound apps behind a proxy”, and its real constraints are no keep-alive and a required buffering proxy, not a throughput ceiling [25]. The same page caps the useful worker count in the 4 to 12 range regardless of class [25]. Switch classes for long blocking calls, streaming, long polling or websockets [25], not because sync sounds slow.
- “Rename
/admin/to harden it”: not a documented control. Nothing in Django’s security topic guide or check reference lists URL obscurity as a mitigation [2][4]. The documented controls are throttling (which Django does not provide [4]), forced authentication such asLoginRequiredMiddleware[8],SESSION_COOKIE_SECUREplus HSTS [3], and staying inside the patch window [11][12].
Synthesis (inferred)
Production readiness checklist, ordered by risk. Each rung is cheap relative to the blast radius of the one above it.
DEBUG = False,ALLOWED_HOSTSexplicit,SECRET_KEYfrom the environment and never in git. A miss here leaks source, settings and locals to anyone who can trigger a 500.check --deploy --fail-level WARNINGexits 0 in CI, with anySILENCED_SYSTEM_CHECKSentry carrying a comment naming the compensating control.- TLS everywhere:
SECURE_SSL_REDIRECT, both*_COOKIE_SECUREflags, HSTS ramped (minutes, days, a year) beforeincludeSubDomainsand long before preload. - Login throttling installed and actually firing, verified by a deliberate lockout in staging. This is the highest-value control Django does not give you.
- Uploads on a separate registrable domain, extensions allowlisted, body size capped in the web server. The PNG-header-plus-HTML exploit needs no authentication.
- Backups running and one restore drill completed. Everything below is availability; this is survival.
- A non-
LocMemCachecache backend, cached or cleaned sessions, and a connection strategy chosen on purpose (one of per-request, persistent, in-process pool, external pooler). collectstaticat build time with a manifest storage, assets behind a CDN.- Structured logging with a request id,
django.security.*routed somewhere a human sees, errors going to an error tracker rather than toADMINS. - Traces initialised after fork, with the p95 of successful requests and DB pool saturation on a dashboard.
- Query budgets on the three hottest endpoints, CSP in report-only, deprecation warnings failing the build.
A request id is the cheapest observability primitive and Django does not ship one. Generate or accept one in the outermost middleware, put it in a contextvars variable, add it to every log record via a filter, echo it in the response header, and stamp it as a span attribute. Without it, correlating a Sentry event to a slow-query log entry to a load balancer access log is manual work; with it, it is a grep. Do this before adding a second observability vendor.
What to measure in the first week after launch. Instrument these seven and ignore everything else until one of them moves:
| Signal | Why this one | Action when it moves |
|---|---|---|
| p95 latency of 2xx responses, per URL pattern | golden-signal latency, split from failures so a fast 500 cannot flatter it | profile the top pattern, do not tune globally |
| 5xx rate, split by view | golden-signal errors | one view dominating means a bug, spread evenly means infrastructure |
DB connections in use versus the server’s max_connections | the ceiling that arrives first for Django, and the one pool config controls | lower min_size per process before raising max_connections |
| worker saturation (busy workers over total) | pre-fork servers queue silently once every worker is busy | add workers only if CPU is idle, otherwise add containers |
| queries per request on the hottest endpoint | N+1 regressions are step functions, not drifts | pin it with assertNumQueries so it never regresses twice |
| cache hit ratio per cache alias | distinguishes “cache is cold” from “cache is wrong” | a ratio near zero usually means a Vary or key-prefix bug |
| worker restarts and OOM kills | memory growth shows up here before it shows up in latency | set max_requests with jitter while you hunt the leak |
The three things most likely to be wrong on day one, from the shape of the defaults rather than from any single source: the cache is still LocMemCache so every worker has its own; CONN_MAX_AGE is still 0 so every request pays connection setup; and check --deploy has never been run with --fail-level WARNING so it has always exited 0. All three are single-line fixes and all three are invisible until load arrives.