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.
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.
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 |
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.
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.
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.
Frequently asked questions
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.
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.
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.
Conclusion
The invariant: one schema, many value layers, real environment variables always on top. Parity is preserved because every environment validates against the identical model.