Settings & Configuration

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, with django-environ 0.14.0, all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text says so. Every example was run on that stack in a scratch project on SQLite, because no PostgreSQL or Redis server was available: the PostgreSQL and Redis snippets are marked as such and were validated with manage.py check and by parsing, not by connecting. A Django project is, in practice, one Python module of upper-case variables plus the rules for choosing which module is loaded. Almost every other page of this section reads from it: the database, the installed apps, the middleware chain, the storage backends, the security switches. In the Bookshelf application that grows across this section, settings decide which database the catalogue lives in, where uploaded covers go, which hosts may serve the site and whether the process is allowed to show a traceback to the world. This page covers the settings module and how Django loads it, two proven layouts (a split package and one environment-driven module), secrets and key rotation, the three settings that most often bite (DEFAULT_AUTO_FIELD, STORAGES, DATABASES), a tour of the reference, the deployment checks, and how to test code that depends on settings.

In a hurry: what goes where

Concern Rule of thumb Section

Which settings file runs

One module, named by DJANGO_SETTINGS_MODULE; manage.py, wsgi.py and asgi.py set a default, the environment overrides it.

The settings module

Values that differ per environment

Read them from environment variables (django-environ or os.environ); keep the file itself free of secrets.

Two layouts: split settings or environment-driven settings, One module driven by the environment (django-environ)

SECRET_KEY

Long, random, unique per environment, never committed; rotate by moving the old value to SECRET_KEY_FALLBACKS.

Secrets: SECRET_KEY and SECRET_KEY_FALLBACKS

Primary key type

Leave DEFAULT_AUTO_FIELD at the 6.0+ default (BigAutoField); pin default_auto_field in reusable apps.

DEFAULT_AUTO_FIELD and AppConfig.default_auto_field

File and static storage

STORAGES (4.2); defining it replaces the defaults, so keep the default and staticfiles aliases.

STORAGES

Database

DATABASES with a default alias; CONN_MAX_AGE/pool and OPTIONS per backend; 0 and a pool under ASGI.

DATABASES

Production readiness

manage.py check --deploy against the production settings, in CI.

Verifying production settings: check --deploy

Tests

override_settings, modify_settings or the pytest-django settings fixture; read settings at call time.

Testing code that depends on settings

The settings module

A settings file is an ordinary Python module whose module-level, upper-case names are the configuration. Because it is Python you can compute values, read the environment, import from other settings modules and use pathlib; because it is Python it also fails on a syntax error and runs arbitrary code at import time, so keep it free of side effects (no network calls, no database access). The settings that django-admin startproject writes are a good first reading: BASE_DIR (a pathlib.Path), SECRET_KEY, DEBUG, ALLOWED_HOSTS, INSTALLED_APPS, MIDDLEWARE, ROOT_URLCONF, TEMPLATES, WSGI_APPLICATION, DATABASES, AUTH_PASSWORD_VALIDATORS, the internationalisation block, STATIC_URL and, since 6.1, a MAILERS dictionary that points at the console email backend.

Choosing the module: DJANGO_SETTINGS_MODULE

Django must be told which module to import, through the DJANGO_SETTINGS_MODULE environment variable, whose value is a dotted Python path (config.settings.prod) that must be importable from sys.path. The three entry points set a default with os.environ.setdefault(…​), so a variable already present in the environment wins:

# manage.py (excerpt) -- wsgi.py and asgi.py contain the same line
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev")

For one command you can instead pass --settings, which wins over the variable:

export DJANGO_SETTINGS_MODULE=config.settings.dev
python manage.py check
python manage.py check --deploy --settings=config.settings.prod   # overrides the variable

The convention used on this page is a config package (project settings, URLs, ASGI/WSGI entry points) next to the apps, as described in Project Structure & Apps. A pitfall worth knowing: if DJANGO_SETTINGS_MODULE names a package whose __init__.py is empty (here config.settings after turning settings.py into a package), Django does not complain; it loads an empty settings module. In the scratch project diffsettings printed only SETTINGS_MODULE = 'config.settings' # and check reported no issues. Point the variable at a concrete module (config.settings.dev), and keep __init__.py empty or import nothing from it.

How the settings are loaded

The algorithm is short: Django starts from django/conf/global_settings.py (every setting has a default there), then loads your module and overrides the defaults. Your module should not import global_settings. Application code reads settings through the single object django.conf.settings, which is an instance of LazySettings, not a module:

flowchart LR env["DJANGO_SETTINGS_MODULE
or --settings"] --> lazy["django.conf.settings
(LazySettings)"] gs["global_settings.py
(defaults)"] --> lazy mod["your settings module
(overrides)"] --> lazy lazy -->|"first attribute access"| read["settings.DEBUG
settings.DATABASES ..."] read --> setup["django.setup()
app registry, logging"] setup --> handler["WSGI/ASGI handler
middleware, URLconf"]
from django.conf import settings

if settings.DEBUG:
    ...  # fine: read through the settings object

# from django.conf.settings import DEBUG   # ImportError: settings is an object, not a module
# from config.settings.dev import DEBUG    # wrong: bypasses the defaults and the override mechanism
# settings.DEBUG = True                    # don't mutate settings at runtime (tests excepted)

Three practical consequences follow:

  • Laziness. The module is imported on the first access, not when django.conf is imported. Since 3.2 the SECRET_KEY check is also deferred to the first access of that setting, so management commands that never touch it can run without a key.

  • Read at call time. Code that copies settings.X into a module-level constant freezes the value at import time and defeats override_settings (see Testing code that depends on settings). Read settings.X inside the function that needs it.

  • Names. Custom settings must be upper case; do not reinvent an existing setting; sequences are lists by convention.

manage.py diffsettings prints what your project changes relative to the defaults: lines marked # are settings that have no default, --all includes unchanged ones and --output unified renders a diff -u style output. It is the fastest way to answer "which value is this process really using?":

$ python manage.py diffsettings --settings=config.settings.dev
BASE_DIR = PosixPath('/srv/bookshelf')  ###
DEBUG = True
MAILERS = {'default': {'BACKEND': 'django.core.mail.backends.console.EmailBackend'}}  ###
ROOT_URLCONF = 'config.urls'  ###
SECRET_KEY = 'dev-only-not-a-secret'
SETTINGS_MODULE = 'config.settings.dev'  ###
...

(diffsettings prints every setting including SECRET_KEY and database passwords: do not paste its output into a ticket from a production environment.)

Standalone use: settings.configure() and django.setup()

Outside a project (a script, a notebook, one component such as the template engine used inside a larger program) you can call settings.configure() instead of setting the variable. Use exactly one of the two, never both and never neither; calling configure() after a setting has been read, or twice, is an error. A standalone script must also call django.setup() once, to populate the application registry:

import django
from django.conf import settings

if not settings.configured:
    settings.configure(
        DEBUG=True,
        TEMPLATES=[{"BACKEND": "django.template.backends.django.DjangoTemplates"}],
    )
django.setup()  # required for standalone use; manage.py and the WSGI/ASGI entry points do it for you

from django.template import engines  # noqa: E402

print(engines["django"].from_string("Hello {{ name }}").render({"name": "Bookshelf"}))
print("configured:", settings.configured, "| DEBUG:", settings.DEBUG)
Hello Bookshelf
configured: True | DEBUG: True

configure() also accepts a default_settings module or class; that replaces Django’s defaults entirely, so you then own every setting the imported code might read. In this mode Django does not touch the process environment (for example it will not set TZ, which it otherwise does from TIME_ZONE on Unix).

Where settings sit in the request/response cycle

Settings are not one lookup; different settings are consumed at different moments, and that decides whether a change needs a restart:

  • Process start. manage.py, get_wsgi_application() and get_asgi_application() import the settings module and call django.setup(): INSTALLED_APPS populates the app registry, LOGGING is applied, and the DATABASES/CACHES/STORAGES/TASKS/MAILERS handlers are created lazily from their dictionaries.

  • Handler start. The handler builds the middleware chain once from MIDDLEWARE and resolves ROOT_URLCONF lazily on the first request. Editing these needs a restart (runserver restarts for you when a file changes).

  • Every request. Some settings are read per request: ALLOWED_HOSTS is checked by HttpRequest.get_host(), the CSRF and cookie settings by the matching middleware, CONN_MAX_AGE when Django decides whether to reuse or close a connection at the start and end of the request, DEBUG when an unhandled exception chooses between the technical error page and the plain 500 page.

  • Never at runtime. Application code must not assign to settings; the supported way to vary a value for a block of code is override_settings in tests (Testing code that depends on settings).

The ALLOWED_HOSTS case is easy to demonstrate with the test client. This is a complete test (it needs no URL beyond the admin login page):

from django.test import SimpleTestCase, override_settings


class HostValidationTests(SimpleTestCase):
    """ALLOWED_HOSTS is read on every request (HttpRequest.get_host())."""

    @override_settings(DEBUG=False, ALLOWED_HOSTS=["bookshelf.example.com"])
    def test_unknown_host_is_rejected(self):
        response = self.client.get("/admin/login/", headers={"host": "evil.example.org"})
        self.assertEqual(response.status_code, 400)

    @override_settings(DEBUG=False, ALLOWED_HOSTS=["bookshelf.example.com"])
    def test_known_host_is_served(self):
        response = self.client.get(
            "/admin/login/", headers={"host": "bookshelf.example.com"}
        )
        self.assertEqual(response.status_code, 200)

For the full path a request takes after the settings are loaded, see Request/Response Cycle and Middleware.

Sync and async notes

The settings object is a plain in-memory attribute lookup, so reading it is safe from synchronous and asynchronous code alike; nothing in django.conf performs I/O after the module has loaded. The settings that interact with concurrency are the database ones: each thread has its own connection, persistent connections (CONN_MAX_AGE) are meant for WSGI and should be disabled under ASGI, where a backend connection pool is the supported way to avoid reconnecting (see DATABASES). Compute expensive or blocking values (a secret fetched from a vault, for example) before the settings module finishes importing, once, rather than lazily from inside a coroutine.

Two layouts: split settings or environment-driven settings

Every real project needs different values per environment. There are two mainstream answers, and they combine well.

Split settings package One module driven by the environment

Shape

config/settings/base.py, dev.py, prod.py, test.py; each imports base and overrides.

One settings.py that reads variables (os.environ, django-environ); the environment selects behaviour.

Strengths

Explicit, greppable, diffable; per-environment code (apps, middleware, debug tools) is easy to express.

Matches twelve-factor and container deployment; the same artefact runs everywhere; secrets never touch Git.

Weaknesses

from .base import * hides origins and tempts you to hard-code production values; the production file can drift.

Behaviour hides behind variables; a missing or mistyped variable shows up at start-up (good) or at runtime (bad).

Best for

Small teams, a few well-known environments, tooling differences (Debug Toolbar in dev only).

Containers, Kubernetes, PaaS, many deployments of the same image.

The hybrid that most teams end up with, and the one used for Bookshelf: a base.py for everything that is the same everywhere, thin dev/test modules for local differences, and a production module that reads every secret and every host-specific value from the environment (either with os.environ or django-environ, shown in One module driven by the environment (django-environ)).

A split settings package

bookshelf/
  manage.py
  config/
    __init__.py
    settings/
      __init__.py        # empty on purpose
      base.py            # shared by every environment
      dev.py             # DEBUG, console mailer, local SQLite
      test.py            # fast hasher, in-memory database
      prod.py            # secrets from the environment, HTTPS hardening
    urls.py
    asgi.py
    wsgi.py
  shop/                  # the Bookshelf app
# config/settings/base.py -- shared by every environment
from pathlib import Path

# config/settings/base.py -> parents[0]=settings, [1]=config, [2]=project root
BASE_DIR = Path(__file__).resolve().parents[2]

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "shop",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True

STATIC_URL = "static/"
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
# config/settings/dev.py -- development: readable, insecure, throw-away
from .base import *  # noqa: F403

DEBUG = True
SECRET_KEY = "dev-only-not-a-secret"  # a throw-away key that is only valid on a developer machine
ALLOWED_HOSTS = []  # with DEBUG=True Django allows localhost, 127.0.0.1 and [::1]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",  # noqa: F405
    }
}

MAILERS = {
    "default": {"BACKEND": "django.core.mail.backends.console.EmailBackend"},
}
# config/settings/test.py -- tests: fast and isolated
from .dev import *  # noqa: F403

PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]  # speed only, never outside tests
DATABASES["default"]["NAME"] = ":memory:"  # noqa: F405
MAILERS = {"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}
# config/settings/prod.py -- production: everything sensitive comes from the environment
import os

from .base import *  # noqa: F403

DEBUG = False
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]  # KeyError at start-up if it is missing: good
SECRET_KEY_FALLBACKS = [
    key for key in os.environ.get("DJANGO_SECRET_KEY_FALLBACKS", "").split(",") if key
]
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
CSRF_TRUSTED_ORIGINS = [
    o for o in os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if o
]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",  # PostgreSQL in the real deployment, see the databases section
        "NAME": os.environ.get("DJANGO_DB_PATH", BASE_DIR / "db.sqlite3"),  # noqa: F405
    }
}

SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 3600  # raise gradually once HTTPS is proven; HSTS is hard to undo
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_REFERRER_POLICY = "same-origin"
X_FRAME_OPTIONS = "DENY"

STATIC_ROOT = BASE_DIR / "staticfiles"  # noqa: F405

MAILERS = {
    "default": {
        "BACKEND": "django.core.mail.backends.smtp.EmailBackend",
        "OPTIONS": {
            "host": os.environ.get("DJANGO_EMAIL_HOST", "localhost"),
            "port": int(os.environ.get("DJANGO_EMAIL_PORT", "587")),
            "username": os.environ.get("DJANGO_EMAIL_USER", ""),
            "password": os.environ.get("DJANGO_EMAIL_PASSWORD", ""),
            "use_tls": True,
        },
    },
}

The module for manage.py, wsgi.py and asgi.py defaults to config.settings.dev; production sets DJANGO_SETTINGS_MODULE=config.settings.prod in the process environment, and the test run uses --settings=config.settings.test. Running it:

$ python manage.py check
System check identified no issues (0 silenced).

$ DJANGO_SETTINGS_MODULE=config.settings.prod python manage.py check --deploy   # no variables set
...
KeyError: 'DJANGO_SECRET_KEY'

$ export DJANGO_SECRET_KEY="$(python -c 'from django.core.management.utils import get_random_secret_key as g; print(g())')"
$ export DJANGO_ALLOWED_HOSTS=bookshelf.example.com
$ python manage.py check --deploy --settings=config.settings.prod
System check identified no issues (0 silenced).

$ python manage.py test --settings=config.settings.test
Found 9 test(s).
...
OK

Habits that keep a split package honest:

  • Put the common values in base.py and only the differences in the others; a value repeated in two environment files will diverge.

  • from .base import * is acceptable here because the file is in your control; add # noqa: F403 and F405 for linters. When you extend a list or dictionary from base, build a new one (MIDDLEWARE = [*MIDDLEWARE, "…​"]) instead of mutating the imported object in place, which is shared with every module that imported it.

  • Never commit prod.py values that are secret; the example above commits only the names of the variables.

  • A test.py that is faster than production (a weak password hasher, an in-memory database) is fine; make sure the tests that matter (migrations, deployment checks) also run once with production-like settings.

One module driven by the environment (django-environ)

django-environ (PyPI 0.14.0, 2026-06-18; its classifiers list Django up to 6.0 but it ran against 6.1.1 here) wraps os.environ with typed readers (bool, int, list, db, cache, …​), defaults, and an optional .env file reader. Install it with pip install django-environ (the import name is environ).

# config/settings/env.py -- one settings module for every environment
from pathlib import Path

import environ

from .base import *  # noqa: F403

BASE_DIR = Path(__file__).resolve().parents[2]

env = environ.Env(
    # (cast, default) pairs
    DJANGO_DEBUG=(bool, False),
    DJANGO_ALLOWED_HOSTS=(list, []),
    DJANGO_CSRF_TRUSTED_ORIGINS=(list, []),
    DJANGO_SECRET_KEY_FALLBACKS=(list, []),
)
# Reads BASE_DIR/.env if it exists; real environment variables always win.
environ.Env.read_env(BASE_DIR / ".env")

DEBUG = env("DJANGO_DEBUG")
SECRET_KEY = env("DJANGO_SECRET_KEY")  # no default: ImproperlyConfigured if missing
SECRET_KEY_FALLBACKS = env("DJANGO_SECRET_KEY_FALLBACKS")
ALLOWED_HOSTS = env("DJANGO_ALLOWED_HOSTS")
CSRF_TRUSTED_ORIGINS = env("DJANGO_CSRF_TRUSTED_ORIGINS")

# DATABASE_URL=postgres://user:password@host:5432/dbname   (PostgreSQL)
# DATABASE_URL=sqlite:///db.sqlite3                        (what this page runs)
DATABASES = {"default": env.db("DATABASE_URL", default=f"sqlite:///{BASE_DIR}/db.sqlite3")}
DATABASES["default"]["CONN_MAX_AGE"] = env.int("DJANGO_CONN_MAX_AGE", default=0)

if not DEBUG:
    SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=True)
    SESSION_COOKIE_SECURE = True
    CSRF_COOKIE_SECURE = True
# .env.example -- committed; copy to .env (git-ignored) and fill in. Values are placeholders.
DJANGO_DEBUG=false
DJANGO_SECRET_KEY=replace-me-with-output-of-get_random_secret_key
DJANGO_ALLOWED_HOSTS=bookshelf.example.com,www.bookshelf.example.com
DJANGO_CSRF_TRUSTED_ORIGINS=https://bookshelf.example.com
DATABASE_URL=sqlite:///db.sqlite3
$ DJANGO_SETTINGS_MODULE=config.settings.env python manage.py check          # no .env, no variables
django.core.exceptions.ImproperlyConfigured: Set the DJANGO_SECRET_KEY environment variable

$ cp .env.example .env
$ DJANGO_SETTINGS_MODULE=config.settings.env python manage.py diffsettings | grep -E "^(ALLOWED_HOSTS|CSRF_TRUSTED|SECRET_KEY)"
ALLOWED_HOSTS = ['bookshelf.example.com', 'www.bookshelf.example.com']
CSRF_TRUSTED_ORIGINS = ['https://bookshelf.example.com']
SECRET_KEY = 'replace-me-with-output-of-get_random_secret_key'

$ DJANGO_DEBUG=true DJANGO_SETTINGS_MODULE=config.settings.env python manage.py diffsettings | grep "^DEBUG"
DEBUG = True        # the real environment beat the .env file

How it behaves, and the traps:

  • Precedence. Env.read_env() does not overwrite variables that already exist (its overwrite parameter defaults to False), so the real environment beats the file. That is what you want: the deployment overrides the repository.

  • Required values. A variable with no default raises ImproperlyConfigured the first time it is read, which is the fail-fast behaviour you want for secrets. A default for a secret defeats the purpose.

  • Casting. env.bool accepts the usual true/false spellings; env.list splits on commas; env.int converts. Remember that every environment variable is a string: DJANGO_DEBUG=False as a plain os.environ value is truthy.

  • Database URLs. env.db() parses DATABASE_URL. A relative SQLite path is relative to the working directory, so build an absolute one from BASE_DIR, as above. Parsing a PostgreSQL URL needs no server (placeholder credentials):

import environ

environ.Env.db_url_config("postgres://bookshelf:change-me@db.internal:5432/bookshelf?sslmode=require")
# {'NAME': 'bookshelf', 'USER': 'bookshelf', 'PASSWORD': 'change-me', 'HOST': 'db.internal', 'PORT': 5432,
#  'OPTIONS': {'sslmode': 'require'}, 'ENGINE': 'django.db.backends.postgresql'}
  • .env in production. Containers and platforms inject real variables; ship no .env file in the image, and keep .env in .gitignore. Mounted secret files (Docker/Kubernetes secrets) can be read with Path("/run/secrets/db_password").read_text().strip() in the settings module; see Kubernetes Secrets and ConfigMaps.

  • Email. django-environ has helpers that produce the deprecated EMAIL_* settings; with 6.1 build a MAILERS dictionary yourself (see The settings reference: highlights), as the production module above does.

  • Other stacks. The same twelve-factor idea (config in the environment, validated at start-up) is covered for FastAPI with pydantic-settings in Settings, Lifespan & Background Tasks and for NestJS in Configuration; the principle is the twelve-factor "Config" rule.

Secrets: SECRET_KEY and SECRET_KEY_FALLBACKS

SECRET_KEY seeds Django’s cryptographic signing. The documentation lists what depends on it: sessions (unless you use the cache session backend, and always when the default get_session_auth_hash() is used), messages when the cookie or fallback storage is used, password-reset tokens, and any use of django.core.signing that is not given another key. It is not used to hash user passwords, so rotating it does not invalidate passwords. A known key lets an attacker forge all of those, which is why the docs describe the consequences as privilege escalation and, for some configurations, remote code execution.

Rules that follow from that:

  • Generate it with get_random_secret_key() (and never copy the one that startproject printed into a repository: its django-insecure- prefix is exactly what the security.W009 deployment check flags).

  • Use a different key per environment; the production key exists only in the production secret store.

  • Load it from the environment or a file at start-up. The settings module must not contain it, and neither must the image.

  • Treat other secrets the same way: database and cache passwords, email credentials, third-party API keys. DEBUG=True pages hide settings whose names contain API, KEY, PASS, SECRET, SIGNATURE or TOKEN, but that is partial protection and you must never run production with DEBUG=True.

from django.core.management.utils import get_random_secret_key

print(get_random_secret_key())  # 50 random characters; run once per environment and store in the secret manager

The deployment checklist’s two ways to load the key (an environment variable, or a file) are:

import os

SECRET_KEY = os.environ["SECRET_KEY"]  # or:

with open("/etc/secret_key.txt") as f:  # a file mounted by your platform, readable only by the app user
    SECRET_KEY = f.read().strip()

Rotating the key with SECRET_KEY_FALLBACKS

Since 4.1, SECRET_KEY_FALLBACKS lets you change the key without logging everyone out. Verification accepts signatures made by the current key or any fallback; new signatures always use SECRET_KEY. The procedure:

  1. Generate a new key and store it.

  2. Set SECRET_KEY to the new key and put the previous value at the start of SECRET_KEY_FALLBACKS; deploy.

  3. Wait until the longest-lived thing signed with the old key (a session, a password-reset link) has expired or been re-issued.

  4. Remove the old key from the list; deploy again.

This script proves the behaviour against django.core.signing (it uses throw-away keys and override_settings, because a second settings.configure() is an error):

from django.conf import settings
from django.core import signing
from django.core.management.utils import get_random_secret_key
from django.test import override_settings

old_key = get_random_secret_key()
new_key = get_random_secret_key()

# 1. Before the rotation: the old key is SECRET_KEY.
settings.configure(SECRET_KEY=old_key)
token = signing.dumps({"user_id": 42}, salt="bookshelf.demo")
print("verify before rotation:", signing.loads(token, salt="bookshelf.demo"))

# 2. Rotation: new key first, old key moved to SECRET_KEY_FALLBACKS.
with override_settings(SECRET_KEY=new_key, SECRET_KEY_FALLBACKS=[old_key]):
    print("verify during rotation:", signing.loads(token, salt="bookshelf.demo"))
    fresh = signing.dumps({"user_id": 42}, salt="bookshelf.demo")
    print("new tokens are signed with the new key:", fresh != token)

# 3. After removing the fallback: old tokens are rejected.
with override_settings(SECRET_KEY=new_key, SECRET_KEY_FALLBACKS=[]):
    try:
        signing.loads(token, salt="bookshelf.demo")
    except signing.BadSignature as exc:
        print("after the fallback is removed:", type(exc).__name__)
verify before rotation: {'user_id': 42}
verify during rotation: {'user_id': 42}
new tokens are signed with the new key: True
after the fallback is removed: BadSignature

A few details from the reference worth keeping in mind:

  • Signing is computationally expensive, so every extra fallback adds work to each check that does not match an earlier key. Keep the list short and remove old values "in a timely manner", as the deployment checklist puts it.

  • security.W025 applies the weak-key test of security.W009 to every entry of SECRET_KEY_FALLBACKS.

  • Uses of the key must not assume it is text or bytes; go through force_str()/force_bytes() if you consume it yourself. Prefer django.core.signing (with a salt) to hand-rolled HMACs; see Sessions, Cookies, Messages & Signing.

  • If the key is missing, Django refuses to start (ImproperlyConfigured: The SECRET_KEY setting must not be empty.); since 3.2 the error appears on first access of the setting, not when the settings load.

DEFAULT_AUTO_FIELD and AppConfig.default_auto_field

When a model declares no field with primary_key=True, Django adds an automatic id column whose type comes from DEFAULT_AUTO_FIELD (or, for an app, from AppConfig.default_auto_field, which defaults to the project setting). The setting arrived in 3.2, which kept AutoField (32-bit) as the default and generated new projects and apps with BigAutoField spelled out; Django 6.0 changed the default to BigAutoField (64-bit). A new project on 6.1 therefore needs no setting at all, and the one in this page keeps it explicit only to document it.

# config/settings/base.py
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"  # already the default since 6.0
# shop/apps.py -- a reusable app pins its own key type
from django.apps import AppConfig


class ShopConfig(AppConfig):
    name = "shop"
    default_auto_field = "django.db.models.BigAutoField"

Why the app-level attribute matters: a reusable app that ships migrations must pin the key type, otherwise makemigrations in a project that chose a different DEFAULT_AUTO_FIELD generates migrations inside the installed app that conflict with its future releases. The scratch project shows both behaviours for a one-field Book model:

$ python manage.py makemigrations shop && grep AutoField shop/migrations/0001_initial.py
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),

# Project says AutoField, the app pins BigAutoField: nothing to do
$ python manage.py makemigrations shop --dry-run --settings=config.settings.legacy_ids
No changes detected in app 'shop'

# Project says AutoField and the app pins nothing: the model follows the project
$ python manage.py makemigrations shop --dry-run -v 3 --settings=config.settings.legacy_ids | grep AutoField
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),

Guidance:

  • Existing projects upgrading to 6.0 or later that still use 32-bit keys can keep them by setting DEFAULT_AUTO_FIELD = "django.db.models.AutoField" (or the app attribute); changing existing tables to bigint is a migration decision, covered in Migrations.

  • Auto-created many-to-many tables respect the setting when created, but the migrations framework cannot yet convert the primary key of an existing auto-created through table. If you switch the setting and generate migrations, the related models and the through table’s foreign keys change but the through table’s own key does not; the reference tells you to add a RunSQL operation with the ALTER TABLE step. Explicitly defined through models are handled normally.

  • For public identifiers consider a UUID primary key or a separate uuid/slug column rather than exposing sequential ids; that is a model-design topic for Models & Fields.

STORAGES

STORAGES (4.2) is a dictionary mapping storage aliases to BACKEND and optional OPTIONS. It replaced DEFAULT_FILE_STORAGE and STATICFILES_STORAGE, which were deprecated in 4.2 and removed in 5.1. Two aliases have special meaning: default (the storage behind FileField/ImageField and default_storage) and staticfiles (the one collectstatic and the static template tag use). Any other alias is yours.

The default value is:

STORAGES = {
    "default": {
        "BACKEND": "django.core.files.storage.FileSystemStorage",
    },
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
    },
}

Defining STORAGES replaces the default; it is not merged with it. If you only override staticfiles and forget default, file uploads lose their storage. Copy the whole dictionary and edit it. OPTIONS are passed to the backend’s constructor as keyword arguments, and a ready instance is available from django.core.files.storage.storages:

# config/settings/base.py (excerpt)
# BASE_DIR is the pathlib.Path defined at the top of base.py
MEDIA_ROOT = BASE_DIR / "media"

STORAGES = {
    "default": {
        "BACKEND": "django.core.files.storage.FileSystemStorage",
    },
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
    },
    # An extra, named storage for generated exports (any alias is allowed).
    "exports": {
        "BACKEND": "django.core.files.storage.FileSystemStorage",
        "OPTIONS": {"location": BASE_DIR / "private_exports"},
    },
}
# config/settings/prod.py (excerpt): hashed static file names for far-future caching
STORAGES["staticfiles"] = {
    "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
}

Run against the production settings, collectstatic produces the hashed copies and the manifest:

$ python manage.py collectstatic --noinput --settings=config.settings.prod
130 static files copied to '.../staticfiles', 130 post-processed.
$ ls staticfiles/admin/css | head -4
autocomplete.5a22097eaab1.css
autocomplete.css
base.6398cd16ee3d.css
base.css
$ ls staticfiles/staticfiles.json
staticfiles/staticfiles.json

Using and testing the aliases (this test ran on 6.1.1 together with the others on this page):

import tempfile
from pathlib import Path

from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage, storages
from django.test import SimpleTestCase, override_settings


class StoragesTests(SimpleTestCase):
    def test_default_aliases_exist(self):
        self.assertEqual(sorted(settings.STORAGES), ["default", "exports", "staticfiles"])

    def test_exports_alias_is_a_separate_storage(self):
        exports = storages["exports"]
        self.assertEqual(Path(exports.location).name, "private_exports")
        self.assertNotEqual(exports.location, default_storage.location)

    def test_override_media_root(self):
        with tempfile.TemporaryDirectory() as tmp, override_settings(MEDIA_ROOT=tmp):
            name = default_storage.save("hello.txt", ContentFile(b"hi"))
            self.assertTrue((Path(tmp) / name).exists())

Notes:

  • Third-party backends plug in the same way: django-storages (S3, Azure, Google Cloud; see the django-storages documentation) and WhiteNoise’s CompressedManifestStaticFilesStorage (see WhiteNoise: Using WhiteNoise with Django) are configured as BACKEND plus OPTIONS. Their classifiers lag the Django release, so check their changelogs before upgrading.

  • Because exports above has no base_url of its own, it would resolve URLs from MEDIA_URL; give private storages no public route at all and serve them through an authenticated view.

  • Uploaded files are untrusted data: see File Uploads & Storage for validation and custom storages, and Static Files & Front-End Assets for collectstatic, WhiteNoise and CDNs.

  • Migrating from the removed settings is mechanical: DEFAULT_FILE_STORAGE = "x" becomes STORAGES["default"] = {"BACKEND": "x"}, STATICFILES_STORAGE = "y" becomes STORAGES["staticfiles"] = {"BACKEND": "y"}. On 5.1 and later the old names are silently ignored, so a forgotten one is a bug, not an error.

DATABASES

DATABASES maps an alias to a dictionary of connection settings; a default alias is required and any number of others may follow (see Transactions, Raw SQL & Multiple Databases for routers). Django 6.1 supports PostgreSQL 15 or newer (14 was dropped), MariaDB 10.11+, MySQL 8.4+, SQLite 3.37+ and Oracle.

Every inner option

Key Default Meaning

ENGINE

''

The backend: django.db.backends.postgresql, mysql, sqlite3, oracle, or a dotted path to a third-party backend.

NAME

''

Database name; for SQLite the file path (forward slashes on Windows).

USER, PASSWORD, HOST, PORT

''

Connection parameters (not used by SQLite). An empty HOST means local (a Unix socket on PostgreSQL); set it on Windows.

OPTIONS

{}

Extra, backend-specific parameters; for PostgreSQL they are passed to the driver’s connection constructor.

ATOMIC_REQUESTS

False

Wrap every view in a transaction on this database.

AUTOCOMMIT

True

Set False only to take over transaction management completely.

CONN_MAX_AGE

0

Connection lifetime in seconds; 0 closes at the end of each request, None keeps it forever. Must be 0 with a pool.

CONN_HEALTH_CHECKS

False

Check a reused persistent connection once per request before using it; re-connect transparently if the server dropped it.

TIME_ZONE

None

The connection’s time zone; leave unset unless a third-party system forces a local zone. Error when USE_TZ is False.

DISABLE_SERVER_SIDE_CURSORS

False

PostgreSQL only: disable server-side cursors for QuerySet.iterator(), required behind a transaction-pooling proxy such as PgBouncer.

TEST

{}

Options for the test database: NAME, CHARSET, COLLATION, DEPENDENCIES, MIGRATE, MIRROR, TEMPLATE, plus Oracle tablespace/datafile keys and CREATE_DB/CREATE_USER/USER/PASSWORD.

Related top-level settings: DATABASE_ROUTERS (route reads/writes among aliases), DEFAULT_TABLESPACE and DEFAULT_INDEX_TABLESPACE, and MIGRATION_MODULES.

PostgreSQL

This is the configuration Bookshelf uses in staging and production. It was loaded and passed manage.py check here, but could not be connected to (no PostgreSQL server in the sandbox), so everything past check is reasoned from the documentation rather than observed:

# config/settings/postgres.py -- needs a PostgreSQL server and psycopg[pool]
import os

from .dev import *  # noqa: F403

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ.get("POSTGRES_DB", "bookshelf"),
        "USER": os.environ.get("POSTGRES_USER", "bookshelf"),
        "PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),  # from the secret store, never committed
        "HOST": os.environ.get("POSTGRES_HOST", "127.0.0.1"),
        "PORT": os.environ.get("POSTGRES_PORT", "5432"),
        "CONN_MAX_AGE": 0,  # must be 0 when "pool" is enabled
        "CONN_HEALTH_CHECKS": True,
        "OPTIONS": {
            "pool": {"min_size": 2, "max_size": 10, "timeout": 10},
            "sslmode": os.environ.get("POSTGRES_SSLMODE", "prefer"),
        },
        "TEST": {"NAME": "test_bookshelf"},
    },
}
$ DJANGO_SETTINGS_MODULE=config.settings.postgres python manage.py check
System check identified no issues (0 silenced).

$ DJANGO_SETTINGS_MODULE=config.settings.postgres python manage.py migrate     # psycopg without the pool extra
django.core.exceptions.ImproperlyConfigured: Error loading psycopg_pool module.
Did you install psycopg[pool]?

The second command is a real pitfall that appeared during the run: "pool" needs the psycopg[pool] extra, not just psycopg. The surrounding facts, all from the database notes:

  • Persistent connections. CONN_MAX_AGE > 0 reuses a connection per thread across requests. Django closes it at the start or end of a request if it exceeded its age or is unusable. Your database must allow at least as many connections as you have worker threads, the development server defeats persistence (it makes a thread per request), and the parameters Django sets on connect are not re-applied if you change them on a live connection.

  • ASGI. The documentation says persistent connections should be disabled under ASGI; use the backend’s connection pool (5.1+, psycopg only, "pool": True or a dict of ConnectionPool options) or an external pooler instead. With a pool, CONN_MAX_AGE must stay 0 and max_lifetime controls connection age; CONN_HEALTH_CHECKS decides whether connections are checked on checkout unless you supply a check callable.

  • Health checks. CONN_HEALTH_CHECKS = True helps after a database restart or a server-side idle timeout: the check runs at most once per request, and only for requests that touch the database.

  • Poolers. Behind PgBouncer in transaction-pooling mode set DISABLE_SERVER_SIDE_CURSORS = True, because server-side cursors do not survive a change of backend connection.

  • Other PostgreSQL options documented for OPTIONS: isolation_level (import IsolationLevel from django.db.backends.postgresql.psycopg_any; applications must then handle serialization failures), assume_role, server_side_binding (psycopg 3.1.8+), a service name with .pg_service.conf/.pgpass, and sslmode and the other libpq parameters. Django sets client_encoding, isolation level and timezone on each connection unless they already have the right value, so configuring them per role with ALTER ROLE saves a few queries per connection.

  • Driver. psycopg 3 support arrived in 4.2; use psycopg[binary] for development or psycopg[c] in production images (psycopg2 is still accepted, but new projects should start on psycopg 3).

More PostgreSQL-specific features (django.contrib.postgres, full-text search) are in PostgreSQL & Database Backends; the schema side is in Migrations.

SQLite, and what this page ran

SQLite is the zero-setup backend and the one every example here used. Two options matter once more than one request is writing:

# config/settings/sqlite_tuned.py
from .dev import *  # noqa: F403

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",  # noqa: F405
        "OPTIONS": {
            "timeout": 20,  # seconds to wait on a locked database
            "transaction_mode": "IMMEDIATE",  # take the write lock when a transaction begins
        },
    },
    "analytics": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "analytics.sqlite3",  # noqa: F405
    },
}
$ DJANGO_SETTINGS_MODULE=config.settings.sqlite_tuned python manage.py shell -c "
from django.db import connections
for alias in connections: print(alias, connections[alias].settings_dict['OPTIONS'], connections[alias].vendor)"
default {'timeout': 20, 'transaction_mode': 'IMMEDIATE'} sqlite
analytics {} sqlite

timeout only makes SQLite wait longer before "database is locked"; it does not remove contention. transaction_mode accepts DEFERRED (default), IMMEDIATE and EXCLUSIVE; IMMEDIATE makes transactions wait for the lock instead of failing late, and the notes advise against ATOMIC_REQUESTS in that case because the lock is then held for the whole view. Treat SQLite as a development and test database for Bookshelf; it has no SELECT …​ FOR UPDATE and limited concurrency.

The settings reference: highlights

ref/settings/ documents several hundred settings; most projects touch perhaps forty. Grouped by what they protect or control, with the version that introduced or changed the ones that matter in 6.1:

Group Settings Notes

Identity and safety

SECRET_KEY, SECRET_KEY_FALLBACKS, DEBUG, ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS, INTERNAL_IPS

ALLOWED_HOSTS is required when DEBUG is False. Since 4.0 CSRF_TRUSTED_ORIGINS entries need a scheme (https://…​;) and may use * subdomain wildcards.

Wiring

INSTALLED_APPS, MIDDLEWARE, ROOT_URLCONF, WSGI_APPLICATION, ASGI_APPLICATION (Channels/Daphne), TEMPLATES, FORM_RENDERER

Order matters for middleware and for apps that override templates or commands.

Data

DATABASES, DATABASE_ROUTERS, DEFAULT_AUTO_FIELD, MIGRATION_MODULES, FIXTURE_DIRS

See the sections above.

Files

STORAGES, MEDIA_ROOT, MEDIA_URL, STATIC_ROOT, STATIC_URL, STATICFILES_DIRS, STATICFILES_FINDERS, FILE_UPLOAD_*, DATA_UPLOAD_MAX_*

DATA_UPLOAD_MAX_NUMBER_FILES (default 100) caps the files in one multipart request and raises TooManyFiles beyond it; DATA_UPLOAD_MAX_MEMORY_SIZE and DATA_UPLOAD_MAX_NUMBER_FIELDS cap the rest.

HTTP and security headers

SECURE_SSL_REDIRECT, SECURE_HSTS_SECONDS, SECURE_HSTS_INCLUDE_SUBDOMAINS, SECURE_HSTS_PRELOAD, SECURE_PROXY_SSL_HEADER, SECURE_CONTENT_TYPE_NOSNIFF, SECURE_REFERRER_POLICY, SECURE_CROSS_ORIGIN_OPENER_POLICY, X_FRAME_OPTIONS, USE_X_FORWARDED_HOST

Most are acted on by SecurityMiddleware or XFrameOptionsMiddleware. SECURE_PROXY_SSL_HEADER is only safe when your proxy sets and strips the header.

Content Security Policy

SECURE_CSP, SECURE_CSP_REPORT_ONLY

Built in since 6.0 (middleware ContentSecurityPolicyMiddleware, csp context processor, django.utils.csp.CSP constants); replaces the django-csp package for new projects.

Sessions and cookies

SESSION_ENGINE, SESSION_COOKIE_*, CSRF_COOKIE_*, CSRF_USE_SESSIONS, LANGUAGE_COOKIE_*

Set the *_SECURE ones to True once HTTPS is enforced.

Authentication

AUTH_USER_MODEL, AUTHENTICATION_BACKENDS, LOGIN_URL, LOGIN_REDIRECT_URL, LOGOUT_REDIRECT_URL, PASSWORD_HASHERS, AUTH_PASSWORD_VALIDATORS, PASSWORD_RESET_TIMEOUT

AUTH_USER_MODEL must be set before the first migration of a project.

Email

MAILERS (6.1), DEFAULT_FROM_EMAIL, SERVER_EMAIL, ADMINS, MANAGERS, EMAIL_SUBJECT_PREFIX

EMAIL_BACKEND, EMAIL_HOST, EMAIL_PORT, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_USE_TLS, EMAIL_USE_SSL, EMAIL_SSL_CERTFILE, EMAIL_SSL_KEYFILE, EMAIL_TIMEOUT and EMAIL_FILE_PATH are deprecated in 6.1, to be removed in Django 2028.

Background work and caching

TASKS (6.0), CACHES, CACHE_MIDDLEWARE_*

TASKS defaults to the immediate backend; CACHES defaults to per-process LocMemCache.

Internationalisation

LANGUAGE_CODE, LANGUAGES, LOCALE_PATHS, TIME_ZONE, USE_I18N, USE_TZ, FORMAT_MODULE_PATH, the *_FORMAT and *_INPUT_FORMATS family

USE_TZ defaults to True since 5.0. USE_L10N was removed in 5.0 (localised formatting is always on). New project templates use TIME_ZONE = "UTC", though the global default stays America/Chicago.

Errors and logging

LOGGING, LOGGING_CONFIG, IGNORABLE_404_URLS, DEFAULT_EXCEPTION_REPORTER*, SILENCED_SYSTEM_CHECKS

Review LOGGING before production.

Testing

TEST_RUNNER, TEST_NON_SERIALIZED_APPS, the TEST key of DATABASES

See Testing code that depends on settings.

The 6.1 transitional settings deserve a separate note because they are meant to disappear: USE_BLANK_CHOICE_DASH (new in 6.1: the default blank label of select widgets changed, and the setting restores the old dashes; it is already deprecated and goes away in Django 2028, replace it by overriding django.db.models.fields.BLANK_CHOICE_LABEL in an app’s ready()), SIGNED_COOKIE_LEGACY_SALT_FALLBACK (its default became False in 6.1 and the setting is deprecated), and the URLIZE_ASSUME_HTTPS setting from 6.0. Transitional settings are the only settings you should ever set for a release or two and then delete.

A production-grade highlights module

This module extends the production settings with the cache, logging, CSP, proxy and check-silencing settings from the table. Redis is shown only as a comment, since no Redis server was available; the LocMemCache default was what ran.

# config/settings/highlights.py
import os

from django.utils.csp import CSP

from .prod import *  # noqa: F403

CACHES = {
    "default": {
        # Redis in production: "django.core.cache.backends.redis.RedisCache"
        # with "LOCATION": "redis://redis:6379/1" (needs redis-py; not run here).
        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
        "KEY_PREFIX": "bookshelf",
        "TIMEOUT": 300,
    }
}

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "root": {"handlers": ["console"], "level": os.environ.get("DJANGO_LOG_LEVEL", "INFO")},
}

# Content Security Policy (built in since 6.0) -- needs the middleware as well.
MIDDLEWARE = [*MIDDLEWARE, "django.middleware.csp.ContentSecurityPolicyMiddleware"]  # noqa: F405
SECURE_CSP = {
    "default-src": [CSP.SELF],
    "img-src": [CSP.SELF, "data:"],
}

# Only when a proxy that you control sets (and strips) this header.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

# The load balancer redirects HTTP to HTTPS, so Django does not have to.
SECURE_SSL_REDIRECT = False
SILENCED_SYSTEM_CHECKS = ["security.W008"]  # a documented decision, not an oversight
$ python manage.py check --deploy --settings=config.settings.highlights
System check identified no issues (1 silenced).

Details worth knowing about those settings: SECURE_CSP values may be lists, tuples or sets (sets are sorted for stable output), Django does not validate directives for you, and a CSP.NONCE source needs the csp context processor (otherwise the security.W027 check fires). The full treatment is on the Content Security Policy page of this section. Without SILENCED_SYSTEM_CHECKS, the same module reports security.W008 (SSL redirect not enabled); silencing a check is a recorded decision, not a way to make a warning go away.

Your own settings

Name them in upper case, give them a default in code (not only in settings.py), document them, and validate them with a system check so a typo fails at manage.py check rather than in production:

# shop/conf.py
from django.conf import settings


def reviews_per_page() -> int:
    """Read at call time, never at import time, so override_settings works."""
    return getattr(settings, "BOOKSHELF_REVIEWS_PER_PAGE", 20)
# shop/checks.py
from django.conf import settings
from django.core.checks import Error, Tags, register


@register(Tags.compatibility)
def check_reviews_per_page(app_configs, **kwargs):
    value = getattr(settings, "BOOKSHELF_REVIEWS_PER_PAGE", 20)
    if not isinstance(value, int) or value < 1:
        return [
            Error(
                "BOOKSHELF_REVIEWS_PER_PAGE must be a positive integer.",
                hint="Set it to e.g. 20 in your settings module.",
                id="shop.E001",
            )
        ]
    return []
# shop/apps.py -- register the check when the app is ready
from django.apps import AppConfig


class ShopConfig(AppConfig):
    name = "shop"
    default_auto_field = "django.db.models.BigAutoField"

    def ready(self):
        from . import checks  # noqa: F401  (importing registers the check)
$ python manage.py check
System check identified no issues (0 silenced).

# with BOOKSHELF_REVIEWS_PER_PAGE = 0 in settings:
$ python manage.py check
SystemCheckError: System check identified some issues:

ERRORS:
?: (shop.E001) BOOKSHELF_REVIEWS_PER_PAGE must be a positive integer.
	HINT: Set it to e.g. 20 in your settings module.

System check identified 1 issue (0 silenced).

Verifying production settings: check --deploy

manage.py check --deploy runs the system checks plus the ones that only make sense in production: HSTS, SSL redirect, secure cookies, DEBUG, ALLOWED_HOSTS, the strength of SECRET_KEY and every fallback, the clickjacking and content-type middleware, the referrer policy, CSP configuration, and, since 6.1, the mailer (mail.E001 flags a development-only email backend in the default mailer; mail.W001 flags a missing default entry). Point it at the production settings with --settings or the environment variable, never at the development module (which has none of the production values), and use --fail-level WARNING in CI to turn warnings into a non-zero exit status (the default level is ERROR).

$ python manage.py check --deploy --settings=config.settings.prod
SystemCheckError: System check identified some issues:

ERRORS:
?: (mail.E001) Your MAILERS setting uses a development-only email backend in the 'default' entry (django.core.mail.backends.locmem.EmailBackend).
	HINT: Use a production-ready email backend, such as the SMTP backend, otherwise email will not be sent.

System check identified 1 issue (0 silenced).

That error came from an earlier draft of the production module that used the in-memory email backend; replacing it with the SMTP mailer shown above made check --deploy --fail-level WARNING exit with status 0. The most common findings and their fixes:

Check Fix

security.W004, W005, W021

Set SECURE_HSTS_SECONDS (ramp up slowly: HSTS cannot be revoked quickly), SECURE_HSTS_INCLUDE_SUBDOMAINS, SECURE_HSTS_PRELOAD, only when all subdomains are HTTPS.

security.W008

SECURE_SSL_REDIRECT = True, or let the load balancer redirect and silence the check.

security.W009, W025

Replace the weak or django-insecure- key and every fallback with get_random_secret_key() output.

security.W012, W016

SESSION_COOKIE_SECURE = True, CSRF_COOKIE_SECURE = True.

security.W018, W020

DEBUG = False; set ALLOWED_HOSTS.

security.W022, E023

Set SECURE_REFERRER_POLICY to a valid value.

mail.E001, mail.W001

Configure a production mailer and a default alias in MAILERS.

Related deployment-checklist items that no check can verify for you: keep runserver out of production, protect database and cache credentials like the secret key, make backups, treat MEDIA_ROOT as untrusted (a web server must never execute an uploaded file), turn on persistent connections or a pool, use cached sessions, rely on the cached template loader (enabled automatically when DEBUG is False), and test error reporting. The complete list is Deployment; error reporting is in Logging & Error Reporting.

Testing code that depends on settings

Django’s test framework swaps settings for the duration of a test and restores them afterwards:

  • @override_settings(NAME=value) on a method, a class or as with self.settings(…​);

  • @modify_settings(INSTALLED_APPS={"append": "…​"}) to add or remove items of list settings (applied after override_settings when both decorate one class);

  • the setting_changed signal, sent twice (apply and restore) with setting, value and enter, so a component that caches a setting can refresh itself; it is importable from django.core.signals outside test code;

  • with pytest-django, the settings fixture (settings.USE_TZ = True inside a test, undone automatically).

from django.test import SimpleTestCase, TestCase, override_settings
from django.test.signals import setting_changed


class CustomSettingTests(SimpleTestCase):
    def test_default_and_override(self):
        from shop.conf import reviews_per_page

        self.assertEqual(reviews_per_page(), 20)
        with override_settings(BOOKSHELF_REVIEWS_PER_PAGE=5):
            self.assertEqual(reviews_per_page(), 5)

    def test_check_reports_bad_value(self):
        from shop.checks import check_reviews_per_page

        with override_settings(BOOKSHELF_REVIEWS_PER_PAGE=0):
            self.assertEqual([e.id for e in check_reviews_per_page(None)], ["shop.E001"])


class OverrideSettingsTests(TestCase):
    @override_settings(DEBUG=True, ALLOWED_HOSTS=["testserver"])
    def test_decorator(self):
        from django.conf import settings

        self.assertTrue(settings.DEBUG)

    def test_setting_changed_signal(self):
        seen = []

        def receiver(sender, setting, value, enter, **kwargs):
            if setting == "LANGUAGE_CODE":
                seen.append((value, enter))

        setting_changed.connect(receiver)
        try:
            with self.settings(LANGUAGE_CODE="es"):
                pass
        finally:
            setting_changed.disconnect(receiver)
        self.assertEqual(seen, [("es", True), ("en-us", False)])
$ python manage.py test
Found 9 test(s).
System check identified no issues (0 silenced).
.........
----------------------------------------------------------------------
Ran 9 tests in 0.016s

OK

Pitfalls: a value copied at import time (PAGE_SIZE = settings.BOOKSHELF_REVIEWS_PER_PAGE at module level) ignores override_settings; code in AppConfig.ready() runs once, so a setting read there needs a setting_changed receiver to follow an override; the documentation warns that settings consulted only during Django’s initialisation do not behave as expected under override_settings, recommends against altering DATABASES, and calls altering CACHES tricky; for those, choose a settings module (--settings=config.settings.test) instead. For the pytest setup see the pytest-django page of this section ( pytest, Factories & Browser Tests ) and the pytest-django helpers documentation.

Pitfalls checklist

  • DEBUG = True or ALLOWED_HOSTS = ["*"] in production; the first leaks source and settings, the second needs your own Host validation.

  • A secret (or a "temporary" production password) committed to Git. Rotate it; deleting the commit is not enough.

  • Overriding STORAGES with only one alias, or leaving a removed DEFAULT_FILE_STORAGE/STATICFILES_STORAGE behind (ignored since 5.1).

  • CSRF_TRUSTED_ORIGINS without a scheme (an error since 4.0) or with http:// for a site served over HTTPS.

  • Trusting SECURE_PROXY_SSL_HEADER when the proxy does not strip the header from client requests.

  • Using SECURE_SSL_REDIRECT behind a TLS-terminating proxy without SECURE_PROXY_SSL_HEADER, which produces an infinite redirect loop.

  • Persistent connections on ASGI or against a pooler; CONN_MAX_AGE above 0 together with "pool".

  • from .base import * followed by in-place mutation of an imported list.

  • Reading settings into module-level constants, or mutating settings at runtime.

  • Pointing DJANGO_SETTINGS_MODULE at a package instead of a module; a typo in the module name that happens to resolve.

  • A default for SECRET_KEY "so that development just works" that ends up in production.

  • Changing DEFAULT_AUTO_FIELD on a project that already has migrations without planning the data change.

  • Treating SILENCED_SYSTEM_CHECKS as a way to make CI green.

Where the books are outdated

The four reference books for this section target Django 1.11 to 3.x. For settings the differences are large:

Older practice Current practice Changed in

One settings.py with the secret key, database password and DEBUG = True committed

Environment-driven or split settings; secrets outside the repository; check --deploy in CI. (The committed-secret and ALLOWED_HOSTS = ["*"] habits are errata, not just age.)

always wrong

MIDDLEWARE_CLASSES

MIDDLEWARE (new-style callables)

removed in 2.0

os.path.join(BASE_DIR, …​) string paths

pathlib.Path (what startproject generates now, and STATICFILES_DIRS/EMAIL_FILE_PATH accept it)

current template

No DEFAULT_AUTO_FIELD; AutoField ids

BigAutoField is the default; pin default_auto_field in reusable apps

3.2 / 6.0

DEFAULT_FILE_STORAGE, STATICFILES_STORAGE

STORAGES with default and staticfiles aliases

4.2, old names removed in 5.1

USE_L10N, USE_TZ = False default, pytz

USE_L10N removed; USE_TZ = True default; zoneinfo replaced pytz as the default implementation in 4.0

4.0 / 5.0

CSRF_TRUSTED_ORIGINS = ["example.com"]

Origins with a scheme ("https://example.com")

4.0

psycopg2 and django.contrib.postgres for JSONField

psycopg 3 (psycopg[binary]/[c]/[pool]); models.JSONField is portable

4.2 / 3.1

Single SECRET_KEY; rotating means logging everyone out

SECRET_KEY_FALLBACKS

4.1

EMAIL_* settings (EMAIL_BACKEND, EMAIL_HOST, …​)

MAILERS with BACKEND and OPTIONS

6.1 (old ones deprecated)

django-csp and hand-written headers

SECURE_CSP, SECURE_CSP_REPORT_ONLY, ContentSecurityPolicyMiddleware

6.0

Celery as the only answer for background work

The TASKS setting and django.tasks

6.0

Deployment recipes written for Heroku’s free tier

Environment variables read with env.db() (django-environ) or os.environ, on any platform; the free tier itself ended in November 2022

ecosystem

Pipenv, virtualenvwrapper, Python 2.7 to 3.7

venv and pip or uv with pyproject.toml; see Virtual Environments & Packaging

ecosystem

What changed recently

Version Change relevant to settings

3.2

DEFAULT_AUTO_FIELD and AppConfig.default_auto_field introduced; SECRET_KEY validated on first access instead of at load.

4.0

zoneinfo default; CSRF_TRUSTED_ORIGINS need a scheme; USE_L10N deprecated (always on from then, removed in 5.0).

4.1

SECRET_KEY_FALLBACKS; SECURE_PROXY_SSL_HEADER accepts a comma-separated list of protocols.

4.2

STORAGES; psycopg 3 support; DEFAULT_FILE_STORAGE/STATICFILES_STORAGE deprecated.

5.0

USE_L10N removed; USE_TZ defaults to True.

5.1

DEFAULT_FILE_STORAGE/STATICFILES_STORAGE removed; PostgreSQL connection pool ("pool" in OPTIONS, psycopg only).

5.2 (LTS)

Last release without BigAutoField as the default, TASKS, built-in CSP and MAILERS; supported until April 2028.

6.0

DEFAULT_AUTO_FIELD defaults to BigAutoField; TASKS; SECURE_CSP/SECURE_CSP_REPORT_ONLY; FORMS_URLFIELD_ASSUME_HTTPS transitional setting removed.

6.1

MAILERS replaces the EMAIL_* settings (those are deprecated); mail.E001/mail.W001 checks; USE_BLANK_CHOICE_DASH (transitional, deprecated); SIGNED_COOKIE_LEGACY_SALT_FALLBACK now False and deprecated; PostgreSQL 14 and MariaDB 10.6-10.10 support dropped; calendar versioning (RemovedInDjango2028Warning, removals "in Django 2028").

When upgrading, run the test suite with python -Wd manage.py test (all warnings shown) before bumping the version, so deprecated settings surface as failures while the old behaviour still works; the per-release notes list the deprecations in a "Features deprecated" section, and the deprecation timeline names the release that will remove each one. 6.2 (the next LTS, planned for April 2027) is the last release under the old versioning; from 2028 releases are named by year.