Django Settings with Pydantic Settings

A mature Django settings.py is usually three hundred lines with forty os.environ.get calls scattered through it, each with its own default, its own type conversion, and its own opinion about what “missing” means. Nothing lists what the project actually requires; the only way to find out is to start it and see what breaks. Swapping that for a single validated model takes an afternoon and turns configuration into something you can read, test and gate in CI. This page shows the migration and the Django-specific details that trip it up, extending the framework integration patterns.

Problem 1: defaults that hide a missing variable

# settings.py — ANTI-PATTERN
SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-key")
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///db.sqlite3")

Both lines look defensive and both are traps. A production deploy that forgets SECRET_KEY does not fail — it silently signs sessions and password-reset tokens with a key that is in your repository and therefore known to anyone who has ever cloned it. A deploy that forgets DATABASE_URL starts against a local SQLite file, accepts writes, and reports itself healthy while the real database sits untouched. In both cases the default converted a loud, immediate failure into a quiet, expensive one.

The rule that fixes this is simple: a default is a statement that this value is optional, and secrets and infrastructure endpoints are never optional. In a settings model, omitting the default makes the field required, and a missing variable raises at import with the field name in the message. The values that genuinely deserve defaults — a page size, a timeout, a feature flag that is off — keep them, and now those defaults are visible in one place rather than scattered across a long module.

A development default turns a missing variable into a silent wrong value With a fallback default, a missing SECRET_KEY boots the site with a repository key and a missing DATABASE_URL boots against SQLite, while a required field fails the deploy immediately. variable missing in production with a fallback default boots with a known-public key as a required field raises at import, names the field sessions forgeable nobody notices deploy stops old version keeps serving
The default is the bug: it converts a missing variable into a running site with the wrong configuration.

Problem 2: hand-parsed booleans and lists

# settings.py — ANTI-PATTERN
DEBUG = os.environ.get("DEBUG", "False") == "True"          # "true" is False here
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",")   # [""] when unset

The DEBUG line is the more dangerous of the two, and its failure mode is counter-intuitive: it fails closed on DEBUG=true and fails open the moment someone writes the comparison the other way round, as != "False", which is a common “fix” when the first form appears not to work. Given how many Django deployments have shipped a debug page to the internet, a value this consequential should not depend on the exact casing of a string in a deployment template.

ALLOWED_HOSTS fails differently. Splitting an empty string yields [""] — a list containing one empty host — rather than an empty list, so Django’s own check for an empty ALLOWED_HOSTS under DEBUG=False never fires. The site then rejects every request with a bare 400 that carries no explanation, which is a memorably unpleasant way to spend a deploy window. A typed field handles both: bool parses the values Django operators actually write, and list[str] parses a comma-separated variable into a genuine list, empty when the variable is empty.

Problem 3: several settings modules that drift

# settings/production.py — ANTI-PATTERN
from .base import *          # noqa
DEBUG = False
ALLOWED_HOSTS = ["example.com"]

The per-environment settings module is a Django tradition, and it is a second configuration system running alongside the first. Every setting now has two possible sources — a variable or a Python file — and which one wins depends on import order that nobody re-reads. Worse, the files diverge: staging gets a middleware entry production never receives, and the difference is discovered when a feature works in one and not the other.

One settings module, one model, and differences expressed as values removes the whole category. settings.py becomes a mechanical translation of the model into Django’s names, and “what is different about production?” is answered by comparing two sets of variables rather than by reading Python.

Secure implementation

# config.py — the schema, imported by settings.py and by nothing else in Django
from pydantic import SecretStr, PostgresDsn, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="forbid")

    # 1. required: no default, so a missing variable fails the boot by name
    secret_key: SecretStr
    database_url: PostgresDsn

    # 2. typed: "true", "1", "yes" all parse; anything else is a ValidationError
    debug: bool = False

    # 3. a real list, empty when the variable is empty — not [""]
    allowed_hosts: list[str] = []

    environment: str = "local"

    @field_validator("debug")
    @classmethod
    def _no_debug_in_production(cls, value: bool, info) -> bool:
        # 4. cross-field rule: the deploy fails rather than the site leaking
        if value and info.data.get("environment") == "production":
            raise ValueError("DEBUG must be False when ENVIRONMENT=production")
        return value


settings = Settings()
# settings.py — a mechanical translation, no logic of its own
from config import settings

# 5. the single unwrap point for the signing key in the whole project
SECRET_KEY = settings.secret_key.get_secret_value()
DEBUG = settings.debug
ALLOWED_HOSTS = settings.allowed_hosts

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": settings.database_url.path.lstrip("/"),
        "USER": settings.database_url.username,
        "PASSWORD": settings.database_url.password,   # 6. components, never the whole URL logged
        "HOST": settings.database_url.host,
        "PORT": settings.database_url.port or 5432,
    }
}

INSTALLED_APPS = [...]      # unchanged: structure stays in Python, values come from the model

The division of labour is the point. Structure — installed apps, middleware order, template configuration — stays in settings.py where it belongs, because it is code rather than configuration and it does not vary between environments. Values come from the model. When someone asks what production needs, the answer is the model’s field list, and when someone asks what the project does, the answer is settings.py. Neither question requires reading the other file.

Structure stays in settings.py, values come from the model Installed apps, middleware and template configuration remain Python code in settings.py, while secret key, debug, allowed hosts and database credentials are assigned from the validated settings model. stays in settings.py INSTALLED_APPS MIDDLEWARE order TEMPLATES, AUTH backends anything identical everywhere comes from the model SECRET_KEY (SecretStr) DEBUG (typed bool) ALLOWED_HOSTS (list) database components
Two files, two questions: what the project does, and what this environment supplies.

Migrating an existing project is best done in one commit rather than gradually. A half-migrated settings.py, where some values come from the model and others still call os.environ.get, has two precedence systems and is harder to reason about than either endpoint. The mechanical route is to list every environment variable the module currently reads, turn each into a field — required if there is no sensible default, typed according to how the value is used — and then replace the reads with assignments. The list is usually shorter than expected, and the exercise itself surfaces two or three variables nothing has read since a refactor two years ago.

The one judgement call in that migration is what to do with values that have a default today. Treat the existing default as evidence rather than as a decision: if production sets the variable explicitly, the default is dead code and the field should be required; if production relies on it, keep it and now it is visible in one place. Anything security-relevant — a signing key, a database endpoint, an allowed-hosts list — goes required regardless of what the old code did, because the cost of a wrong value there is unbounded and the cost of a failed deploy is a few minutes.

Gotchas and version-specific behaviour

  • Django resolves DJANGO_SETTINGS_MODULE before importing your module, so config.py must never import django.conf.settings — that direction creates a cycle that appears under gunicorn but not under manage.py.
  • SECRET_KEY_FALLBACKS (Django 4.1+) accepts a list of old keys so a key rotation does not log every session out; model it as list[SecretStr] and unwrap in the same place as the primary key.
  • PostgresDsn in pydantic v2 is a parsed URL object, not a string — str(...) it where a library wants a URL, and use its components where Django wants them separately.
  • Django’s own SECRET_KEY requirement is checked lazily in some code paths, so a project can start with an empty key and only fail when something signs a value; a required model field removes that possibility before Django is involved at all.
  • manage.py check --deploy still catches the settings Django itself cares about; the model does not replace it, and the two together cover more than either alone.
  • Under pytest, DJANGO_SETTINGS_MODULE is usually set by configuration rather than the shell, so the model is constructed during collection — an isolation fixture that clears the environment must run before that, or use a settings override fixture.
  • list[str] parses a comma-separated variable; if a host contains a comma you have bigger problems, but for genuinely structured values prefer a JSON-encoded variable and a nested model.

The version-specific items are worth re-reading after any framework upgrade. Django and pydantic both move fast enough that a detail here — how a URL type exposes its components, which fallback settings exist — can change under a minor release, and the model’s rejection tests are what will tell you.

Production parity checklist

  • Every secret is a SecretStr with exactly one get_secret_value() call in the project.
  • No os.environ reference remains in settings.py — grep for it as a review step.
  • The CI pipeline imports config with the target environment’s variables so a missing key fails the build.
  • ENVIRONMENT=production plus DEBUG=true is a validation error, not a deploy.
  • .env is in .gitignore and is a local convenience only; deployed environments inject variables.
  • manage.py check --deploy runs in CI alongside the model construction.
Where a Django configuration error is caught after the migration With a validated model, a missing or malformed value fails during CI or at container start, so the deploy stalls with the previous version still serving instead of the error reaching a request. CI import model + check --deploy container start settings.py imports it rollout gate stalls on crash-loop a request never reached three gates before a user is involved, all from one import
The same import runs in CI and at container start, so both catch the same class of error.

Frequently asked questions

Does pydantic-settings replace django-environ?

It covers the same ground and adds a schema. django-environ parses individual values on demand, which means the set of variables a project needs is only discoverable by reading every call site. A settings model declares every value in one place, validates them together at import, and fails the boot with a message naming the field. If you already use django-environ and it works, the migration is worth doing when you want CI to be able to answer “is this environment complete?” — that question needs a schema, and a schema is what the model is.

How do I keep multiple Django settings modules?

Keep one model and one settings module. Environment differences belong in values, not in Python files — a per-environment settings module is a second configuration system that drifts from the first, and the drift is always discovered at the worst moment. Where an environment genuinely needs different structure, such as an extra debugging middleware locally, gate it on a boolean field from the model inside the single settings module. That keeps the difference visible in one file and makes it a value somebody can flip rather than a file somebody must remember to edit.

What about settings Django reads lazily?

Django resolves some settings the first time something touches them rather than at startup, which is how a misconfiguration reaches a request instead of the boot. The model closes that gap from the other side: because every value is a field constructed at import, a missing or malformed one raises before Django’s laziness is relevant. The setting Django would have resolved lazily is already known to exist and to be well-formed by the time any view runs. Where a setting is genuinely optional and only some code paths need it, model it as an optional field and assert it at the point of use, so the failure names the feature that required it rather than surfacing as an attribute error.

Where does SECRET_KEY go?

In the model as a SecretStr, unwrapped on exactly the one line in settings.py that assigns Django’s SECRET_KEY. Everywhere else in the project it stays wrapped and masks itself in logs and tracebacks. If you rotate signing keys, add SECRET_KEY_FALLBACKS as a list of SecretStr alongside it and unwrap in the same place, so a rotation does not invalidate every active session. Keeping both unwraps adjacent means the whole exposure surface for your signing keys is two lines that a reviewer can check at a glance.

Key takeaways

Replacing scattered os.environ.get calls with one validated model changes three things at once: missing values fail the boot instead of defaulting to something wrong, DEBUG and ALLOWED_HOSTS are parsed by types rather than by string comparisons, and the project’s configuration requirements become a list anyone can read. The Django-specific work is small — assign from the model, unwrap the signing key once, keep structure in settings.py — and the payoff is that CI can construct the same model against a target environment and prove it complete before a deploy starts.

Resist the pull back toward per-environment settings modules while you are there. One module and one model means differences are values, comparable with a diff of two variable sets, rather than Python that has to be read and reasoned about. For the broader picture of how this pattern extends to other frameworks and worker processes, see the framework integration topic this page sits under.