Multi-Environment Settings Override
The schema must be identical in dev, staging, and production; only the values differ. The failure mode is a per-environment settings subclass that quietly grows different fields until “works in staging” stops meaning anything. This page layers environment-specific values over one schema. It rounds out type-safe validation with pydantic-settings alongside schema evolution.
The principle underneath everything on this page is a clean separation between schema and values. The schema — the set of fields, their types, their validators — is a property of the application and must be identical in every environment, because it is the contract the code depends on. The values are a property of the deployment and legitimately differ: a different database in staging than production, debug logging on locally and off in prod, a feature flag flipped on for a canary and off everywhere else. Multi-environment configuration done well varies only the second and never the first, so that “it validated in staging” is a real guarantee about production rather than a coincidence of two subclasses that happened to agree that day.
The anti-pattern this replaces is the per-environment subclass — a DevSettings, a StagingSettings, and a ProdSettings, each one adding or dropping a field. It feels natural at first and rots quickly: production grows a sentry_dsn the others lack, development drops the TLS requirement “just for local”, and within months the three schemas have diverged so far that testing against one tells you nothing about the others, and the word “parity” has quietly stopped meaning anything at all. The fix is structural — one Settings class whose fields never vary by environment — and the mechanism is layering value sources beneath that single class, which is exactly what the rest of this page builds.
Secure implementation
# config/settings.py
import os
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
ENV = os.environ.get("APP_ENV", "dev") # dev | staging | production
class Settings(BaseSettings):
# One schema; the env_file chooses which values layer in.
model_config = SettingsConfigDict(
env_file=(".env", f".env.{ENV}"), # later file wins; real env wins over both
extra="forbid",
)
database_url: str
api_key: SecretStr
debug: bool = False # safe default, flipped per environment
feature_new_checkout: bool = False
settings = Settings()
A single class is used everywhere. env_file layers a base .env and an environment overlay; real OS environment variables (injected in staging/production) still outrank both, preserving precedence. There are no diverging subclasses.
The env_file tuple is the key detail. Passing (".env", f".env.{ENV}") tells pydantic-settings to read both files in order, with the later file winning on any key they both define — so .env holds the shared baseline and .env.dev/.env.staging/.env.production holds only the handful of values that differ. This keeps each overlay small and readable: it is a small diff against the baseline, not a full copy of it, so a reviewer can see at a glance exactly what staging changes from the shared default, and nothing else. And because real environment variables sit above both files in pydantic-settings’ source order, the injected variables that your orchestrator sets in staging and production still win over anything in a file, which is precisely the precedence you want — files are developer conveniences committed for reference, and injected variables are the deployed truth that always has the final say.
Notice what does not change across environments: the class, the field list, the types, the validators. APP_ENV selects which overlay file to read, but it does not branch the schema — there is no if ENV == "prod" adding a field. That discipline is what makes the CI construction test meaningful: building Settings() against staging’s variables proves the same thing it proves against production’s, because it is the same model both times.
A subtle point about the tuple is that order is significant and later wins, which is the opposite of how some people expect a list of files to work. Put the base first and the overlay second — (".env", f".env.{ENV}") — so the environment-specific values override the shared ones. Reverse them and the base would clobber your overlay, quietly undoing every per-environment change. It is also fine for an overlay to be absent: if .env.dev does not exist, pydantic-settings simply skips it and the base plus defaults supply the values, so a developer who has not created a local overlay still gets a working configuration. This tolerance is deliberate and useful — the overlay is optional refinement, not a required file whose absence breaks startup.
One more consequence worth internalising: because injected environment variables sit above both files, the files can be committed (minus secrets) without risk of overriding production. A committed .env.production with a non-sensitive log level and timeout is harmless, because the moment production injects a real variable of the same name, the injection wins. This is what lets you keep environment configuration visible in the repository for review while still giving the deployment the final say — the files document intent, the injected variables are the authority.
Configuration reference
| Element | Type | Default | Security implication |
|---|---|---|---|
APP_ENV |
str |
"dev" |
Selects the overlay file only |
env_file tuple |
files | — | Later file wins; env wins over files |
extra="forbid" |
bool | — | Keeps every environment on one schema |
| feature flag field | bool |
False |
Off by default; flip per environment |
SecretStr |
masked | — | Secrets never logged across environments |
The table’s rows sort cleanly into two groups, and seeing which is which is the whole mental model. env_file, APP_ENV, and the concrete field values are the things that legitimately vary by environment — the overlay chooses different values, APP_ENV chooses the overlay. The schema itself — the field list, the types, extra="forbid", SecretStr on credentials, the validators — is fixed, identical everywhere, and never keyed on the environment. APP_ENV in particular is a source selector, not a schema switch: it decides which file to layer, and it must never be read elsewhere to branch behaviour, because the moment if APP_ENV == "prod" appears in application logic you have reintroduced the per-environment divergence the single schema was meant to prevent. Keep environment-awareness at the configuration boundary, and let the rest of the code see only validated values.
This is why APP_ENV deserves special caution. It is genuinely useful for selecting the overlay, but it is also the most tempting variable to reach for elsewhere — a quick if APP_ENV == "prod": to skip a slow debug path, disable a safety check, or change a default. Every such branch is a hidden per-environment schema in disguise: two code paths that only one environment ever exercises, untested against each other, drifting exactly like the subclasses this whole approach was meant to avoid. The rule is to let APP_ENV choose the overlay and nothing else, and to express any behaviour that genuinely differs by environment as a field — a boolean flag, a tuning value — so it is validated, documented, and visible in the one schema rather than hidden in a conditional. When behaviour varies by a setting rather than by an environment check, it is testable in every environment by simply setting the field.
How one value resolves across the layers
The fastest way to build intuition for the layering is to trace a single field through it. Take log_level, with a field default of "INFO". The base .env sets LOG_LEVEL=INFO; the .env.staging overlay sets LOG_LEVEL=DEBUG; and in production the orchestrator injects LOG_LEVEL=WARNING as a real environment variable. In staging, no injected variable is present, so the overlay wins and the value is DEBUG. In production, the injected variable outranks both files, so the value is WARNING. Delete the injected variable and the production process would fall back to the overlay, then the base, then the field default — each source consulted in order until one supplies a value.
That single trace contains the whole model. The precedence is fixed and predictable: injected environment variables, then the environment overlay, then the base file, then the field default, with the first source that provides a value winning. Nothing about which value a field holds is mysterious once you know that order, and every “I set it but the app ignored it” confusion resolves to a higher-priority source having already supplied the value. When a value looks wrong, walk the layers from the top: is an injected variable overriding what you expected? Is the overlay setting something the base already set? The resolved value is always the topmost source that spoke.
This is also the single most useful debugging tool for multi-environment configuration: log the fully resolved settings — with secrets masked by SecretStr — once at startup, in every environment. A one-line dump of settings.model_dump() (which shows SecretStr fields as **********) tells you exactly what the process actually resolved, cutting through any confusion about which layer supplied which value. When staging and production behave differently, diffing these two dumps points straight at the differing field, turning “something is different somewhere” into “the timeout is 3 here and 30 there” in seconds. The resolution order is deterministic, so the resolved dump is the ground truth; trust it over your assumptions about which file or variable should have won.
Step-by-step deployment parity
- Local dev —
APP_ENV=dev; values come from.env+.env.dev. - CI —
APP_ENV=stagingwith injected variables overriding the overlay. - Staging/Production — the orchestrator sets
APP_ENVand injects real variables that outrank the overlay file; the sameSettingsclass validates them.
The parity these three steps produce is structural, not aspirational. In every one of them the same Settings class does the validating, so there is no separate “production config path” that could diverge from the development one — the only thing that changes is which sources supply the values. Locally, an .env plus a .env.dev overlay; in CI, an overlay plus injected variables that let you test the staging shape; in production, injected variables that outrank any file. Because the model is constant across all three, a value that constructs cleanly in CI constructs cleanly at boot, and the CI step becomes a genuine gate: build Settings() against each environment’s real variable set, and a missing or malformed value fails the pull request instead of the deploy. This is the concrete benefit of refusing to branch the schema: a single construction check per environment is a complete verification, because the model it builds is the exact model production runs, not a sibling that merely resembles it.
Security boundaries & operational guardrails
- One settings class, identical fields, for every environment — no subclasses.
- Real environment variables always outrank
.env*files (override=Falseprecedence). - Feature flags default to off and are flipped per environment, never hard-coded on.
- Overlay files for non-prod are uncommitted or carry no real secrets.
extra="forbid"guarantees an environment cannot quietly add an unvalidated key.
Two of these guardrails carry most of the safety. The precedence rule — real environment variables always outrank .env* files — is what keeps overlays as conveniences rather than authorities: an operator can inject a value in production and be certain a committed overlay file cannot silently override it. And the “flags default to off” rule is what makes a new feature safe by construction: a flag that defaults to False is inert everywhere until an environment deliberately flips it, so shipping the code and enabling the feature are two separate, reversible steps. This decoupling is the operational payoff of treating flags as settings — a risky feature can reach production dark, be enabled for a single canary environment, watched, and either widened or reverted with a configuration change that needs no deploy and no rollback of code.
The precedence guardrail deserves one more note because it is where subtle production incidents hide. If some code loads a .env file with override=True — a habit copied from tutorials that predate proper source ordering — it will clobber the injected environment variables that were supposed to win, and a value an operator set in production gets silently replaced by whatever the committed file says. The symptom is maddening: the variable is set correctly in the environment, yet the app uses the file’s value. The fix is to never override the environment from a file, and to trust pydantic-settings’ default source order, which already places injected variables above files. When you see a value that ignores its injected variable, a rogue override=True load is the first place to look. The remaining guardrails protect the single-schema invariant from the two ways it usually erodes — a subclass that adds a field, and an overlay that carries a real secret into version control — and extra="forbid" is the enforcement that turns “we all agreed to keep one schema” into a rule the model checks at boot in every environment automatically. Without forbid, an environment could set a variable the schema does not declare and have it silently ignored, so a value someone thought they were configuring does nothing — a particularly confusing failure because the variable is set, just unread. With forbid, that stray variable is a loud extra not permitted error naming the offender, which is exactly the feedback you want: it forces every environment to speak only the vocabulary the one schema defines, and it surfaces the leftover variables of a half-finished migration instead of letting them linger unnoticed.
Troubleshooting
- Staging behaves unlike production — a value differs in the overlay or an injected variable; log the resolved values (not secrets) per environment.
- Feature flag stuck off — the boolean string was not parsed; route it through the model, not
bool(). See Feature Flags with Pydantic Settings. - Overlay overrides an injected secret — files must sit below env vars; check the source order.
extra not permitted— one environment sets a key the schema lacks; add it to the single schema or stop setting it.
Most of these symptoms are precedence or parsing confusions with the same underlying cause: a value is coming from a different source than you assumed. “Staging behaves unlike production” almost always means a value differs — in an overlay, or in an injected variable — so the fastest diagnosis is to log the resolved configuration (with secrets redacted) in each environment and diff the two dumps; the one field that differs points straight at the culprit and ends the guesswork. “Overlay overrides an injected secret” is the precedence rule violated, which should be impossible with the standard source order, so it signals that something is loading the file with override=True or reading it after the environment. And extra not permitted is extra="forbid" catching an environment that sets a key the shared schema does not declare — the fix is never to relax forbid, but either to add the field to the one schema (if the value is genuinely needed) or to stop setting the stray variable (if it is a leftover). Relaxing forbid to make the error go away is the one move to resist, because it re-opens the door to silent per-environment drift that the whole approach exists to close.
Frequently asked questions
What belongs in the base file versus an overlay
The split between the base .env and the per-environment overlay is worth getting right, because it determines how readable your configuration stays as environments multiply. The base file holds the shared defaults — the values that are the same in most environments, or a sensible starting point that overlays can adjust. Each overlay holds only the differences for its environment: the staging database URL, the production log level, the flags a canary enables. Kept this way, an overlay is a short diff you can read in seconds, and answering “what does production change from the default?” is a matter of opening one small file rather than comparing two large ones. The temptation to copy the whole base into each overlay “to be explicit” is the thing to resist — duplicated values drift, and a change to a shared default then has to be made in every overlay instead of once in the base.
A related discipline is to keep secrets out of the committed overlays entirely. A .env.dev with a throwaway local password is fine; a .env.production with a real credential is a leak waiting to happen, because it lives in version control. Production secrets belong in injected environment variables (which outrank the files anyway) or a dedicated secret manager, so the production overlay, if it exists at all, carries only non-sensitive differences such as a log level or a timeout. This keeps the repository safe to share while the layering still gives every environment the values it needs. A practical convention is to commit an .env.example listing every variable with placeholder values, so a new developer knows exactly what to create locally, while the real .env and any secret-bearing overlay stay gitignored. The example file doubles as documentation of the configuration surface, and because the model’s field list is the authoritative inventory, you can even generate or check the example against the schema to keep the two in sync.
Feature flags are just boolean settings
Feature flags fit this model with no special machinery: a flag is a boolean field on the same Settings class, with a safe default of False, flipped per environment through the same overlay-and-injection mechanism as any other value. That framing gives you several things for free. The flag validates like any setting, so FLAG=maybe fails at startup rather than being silently misread. It documents itself, because the field list is the authoritative inventory of every flag the service reads. And it defaults off, so deploying the code and enabling the feature are two separate steps — you ship the flagged code inert, flip the flag in one environment to canary it, and roll it wider or back with a simple configuration change rather than a full redeploy of the code. Because the flag is a real bool on the model, the truthy-string bug that plagues hand-rolled flags (bool("false") being True) simply cannot occur.
How do I override pydantic-settings per environment?
Keep one schema and vary only the values. Select an environment-specific .env file (.env.staging, .env.production) via env_file, and let real OS environment variables override it. Never create a separate settings subclass per environment. Concretely, pass a tuple to env_file — (".env", f".env.{ENV}") — so the base and overlay layer with the later file winning, and read APP_ENV only to choose that overlay, never to branch application logic. The result is one class, one field list, and one set of validators, with the environment supplying different values underneath. That is the whole technique, and its power is that it makes divergence structurally impossible rather than merely discouraged.
Should I have one settings class per environment?
No. Diverging subclasses drift apart and defeat parity. Use a single class whose fields are identical everywhere; the environment supplies different values through env files and injected variables. The subclass approach fails slowly and convincingly: it works fine at first, when the subclasses are nearly identical, and only becomes a problem months later when production has quietly grown fields the others lack and “it passed in staging” no longer predicts production behaviour. By then the divergence is entrenched and painful to unwind. Starting with one class avoids that trap entirely — a value that is optional in one environment is modelled as an optional field with a default, not as a field that exists in one subclass and not another.
How do feature flags fit into multi-environment settings?
Model them as boolean fields on the same settings object with safe defaults, then flip them per environment via variables. They validate and document themselves like any other setting. A flag defaulting to False is inert until an environment deliberately enables it, so you can ship the flagged code to production dark, flip the flag in one environment to canary the feature, and widen or revert it with a configuration change rather than a redeploy. Because the field is a real bool, FLAG=false parses correctly to False (unlike a hand-rolled bool("false"), which is True), and FLAG=maybe fails validation at startup instead of being silently misread — so a misconfigured flag is a loud error, not a mystery in production behaviour.
Key takeaways
The invariant: one schema, many value layers, real environment variables always on top. Parity is preserved because every environment validates against the identical model. Everything else is mechanism in service of that one idea — layering a base .env and a per-environment overlay, selecting the overlay with APP_ENV, and letting injected variables outrank both — but the idea itself is what buys you the guarantee that “it validated in staging” means something about production.
The failure to avoid is the per-environment subclass, and the reason to avoid it is that it silently breaks the guarantee: two schemas that drift apart can each be internally valid while disagreeing about what configuration even is, so a test against one environment’s schema proves nothing about the other’s. Keep the schema single and enforced with extra="forbid", keep feature flags as boolean fields that default off and flip per environment, keep secrets as SecretStr sourced from real injection rather than committed overlays, and let a CI construction test prove each environment satisfies the one model. Do that, and moving a change from a laptop to staging to production stops being a leap of faith and becomes a validated, repeatable step.