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.
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.
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_MODULEbefore importing your module, soconfig.pymust never importdjango.conf.settings— that direction creates a cycle that appears under gunicorn but not undermanage.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 aslist[SecretStr]and unwrap in the same place as the primary key.PostgresDsnin 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_KEYrequirement 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 --deploystill 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_MODULEis 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
SecretStrwith exactly oneget_secret_value()call in the project. - No
os.environreference remains insettings.py— grep for it as a review step. - The CI pipeline imports
configwith the target environment’s variables so a missing key fails the build. ENVIRONMENT=productionplusDEBUG=trueis a validation error, not a deploy..envis in.gitignoreand is a local convenience only; deployed environments inject variables.manage.py check --deployruns in CI alongside the model construction.
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.