Managing Environment Variables with Pydantic Settings
Key takeaways:
- Declare each variable as a typed field so the environment is parsed and validated, not merely read.
- Matching is case-insensitive after the prefix is stripped —
app_max_connectionspopulatesmax_connections. extra="forbid"catches misspelled keys in a.envfile but not misspelled process environment variables. This is verified below.- Complex types are parsed as JSON, so a list field wants
["a","b"], nota,b. - Construct
Settingsonce at startup, so a bad value stops the deploy rather than the first request that touches it.
This is the hands-on guide under Configuration Management. It covers how variables become fields; for choosing which file each environment reads and how credentials are injected, see Secrets and .env Files Per Environment.
The Problem This Solves
os.environ["DATABASE_URL"] has three defects and they compound. It returns a string, so MAX_CONNECTIONS arrives as "25" and the bug appears wherever someone forgot int(). It fails at the moment of access, so a variable read only by the export endpoint is missing for a week before anyone finds out. And the reads are scattered, so no single place in the codebase describes what the service needs to run.
A BaseSettings model fixes all three at once — but only if you know what it actually does with the environment, because several of its behaviours are not what the field declaration suggests. The specific one that costs people real incidents is at the end of this page.
Why It Happens
BaseSettings is a normal Pydantic model with an unusual way of getting its input. On construction it asks a list of settings sources, in priority order, for a dict of values, merges them, and validates the merged dict exactly as any Pydantic model validates a payload. The default sources, highest priority first, are: arguments passed to Settings(...), the process environment, the dotenv file, the secrets directory, and finally the field defaults.
Two consequences follow, and both explain behaviour that otherwise looks arbitrary.
First, validation happens after merging, not per source. So a value from a .env file and a value from the environment are indistinguishable by the time constraints are applied, and every field is validated in one pass — you get all the errors at once rather than the first one.
Second, the sources do not work the same way. The environment source performs lookups: for each field it knows about, it constructs the expected variable name and asks the environment for it. The dotenv source performs a scan: it reads the whole file and returns every key in it. That asymmetry is invisible until you turn on extra="forbid" and assume it protects you from typos.
The Fix
1. Declare the model
class DatabaseConfig(BaseModel):
host: str
port: int = 5432
pool_size: int = 10
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="APP_",
env_file=ENV_FILE,
env_nested_delimiter="__",
case_sensitive=False,
extra="forbid",
)
service_name: str
environment: Literal["development", "staging", "production"]
max_connections: int = Field(default=10, ge=1, le=100)
request_timeout_s: float = 3.0 # nothing sets this: the default survives
debug: bool = False
feature_flags: list[str] = []
api_key: SecretStr = SecretStr("unset")
db: DatabaseConfig
Given a .env file containing APP_SERVICE_NAME, APP_ENVIRONMENT=development, APP_DEBUG=yes, a JSON array in APP_FEATURE_FLAGS, and APP_DB__HOST/APP_DB__PORT — plus a process environment supplying APP_ENVIRONMENT=production, APP_API_KEY, and a deliberately lower-case app_max_connections=80 — this is what the process actually resolves:
$ GET /resolved
200 OK
{
"values": {
"service_name": "checkout-from-dotenv",
"environment": "production",
"max_connections": 80,
"request_timeout_s": 3.0,
"debug": true,
"feature_flags": [
"beta-checkout",
"new-pricing"
],
"api_key": "**********",
"db": {
"host": "db.dev.internal",
"port": 5432,
"pool_size": 10
}
},
"python_types": {
"max_connections": "int",
"request_timeout_s": "float",
"debug": "bool",
"feature_flags": "list",
"db": "DatabaseConfig"
},
"where_each_value_came_from": {
"service_name": ".env file (nothing in the environment set it)",
"environment": "process environment, overriding development in the .env file",
"max_connections": "process environment as lower-case app_max_connections",
"request_timeout_s": "the class default; no variable of either kind set it",
"db.pool_size": "the nested model's default; only DB__HOST and DB__PORT were set"
}
}
Five separate behaviours are demonstrated there, none of them asserted:
- Precedence is real.
environmentis"production"from the process environment even though the.envfile saiddevelopment. The environment source outranks the file. - Case does not matter.
max_connectionsis80, supplied asapp_max_connections. After the prefix is stripped, matching is case-insensitive. - Defaults survive.
request_timeout_sstayed3.0because nothing set it, anddb.pool_sizestayed10because only two of the three nested fields were supplied. - Types are real types.
"yes"became the booleanTrue,"80"became anint, the JSON array became alist, and the nested block became aDatabaseConfiginstance — not a dict. - Secrets are masked.
api_keyrenders as**********.SecretStroverrides__str__and__repr__, so an accidental log of the settings object cannot leak it;get_secret_value()is the only way out, and it greps.
2. Let bad values stop the deploy
Because validation runs over the merged dict, a broken environment produces one report of everything wrong:
$ GET /misconfiguration
200 OK
[
{
"case": "APP_ENVIRONMENT=prod (not one of the allowed values)",
"result": "ValidationError",
"errors": [
{
"field": "environment",
"type": "literal_error",
"msg": "Input should be 'development', 'staging' or 'production'"
}
]
},
{
"case": "APP_MAX_CONNECTIONS=500 (outside the declared range)",
"result": "ValidationError",
"errors": [
{
"field": "max_connections",
"type": "less_than_equal",
"msg": "Input should be less than or equal to 100"
}
]
},
{
"case": "APP_MAX_CONNECTIONS=lots (not an integer)",
"result": "ValidationError",
"errors": [
{
"field": "max_connections",
"type": "int_parsing",
"msg": "Input should be a valid integer, unable to parse string as an integer"
}
]
},
{
"case": "APP_SERVICE_NAMES=typo (extra='forbid' catches the misspelling)",
"result": "constructed without error"
}
]
Literal catches the almost-right value, Field(ge=..., le=...) catches the out-of-range one, and the type itself catches the unparseable one. Each error names the field, the error type, and a message you can paste into an incident channel.
3. The one that will bite you: extra="forbid" and misspelled variables
Look at the last case above. APP_SERVICE_NAMES — a plural typo for service_name — constructed without error, with extra="forbid" set. The label on that case was written expecting the opposite, and the run corrected it.
This is worth isolating, because the advice to set extra="forbid" so that unknown variables fail at startup is repeated everywhere, and it is only half true:
$ GET /typo-detection
200 OK
[
{
"case": "unknown key PROBE_NAM in the .env file",
"result": "ValidationError",
"errors": [
{
"field": "probe_nam",
"type": "extra_forbidden",
"msg": "Extra inputs are not permitted"
}
]
},
{
"case": "unknown variable PROBE_NAM in the process environment",
"result": "constructed: name='real'"
}
]
The identical misspelling raises extra_forbidden from a .env file and is silently ignored from the process environment. The mechanism is the asymmetry described above: the dotenv source hands over every key it read, so an unknown one reaches the model and is refused. The environment source only ever looks up variables matching fields that exist, so PROBE_NAM is never fetched and the model never learns it was set.
The practical consequence is that extra="forbid" does not protect your production deploys, where configuration arrives as real environment variables. It protects developers editing .env files, which is worth having, but it is not the safety net it is usually sold as. If you need typo detection in deployed environments, the check has to be explicit — see the verification below.
Verification
Assert the two properties that matter, then close the gap extra="forbid" leaves:
def test_missing_required_variable_fails_fast(monkeypatch):
monkeypatch.delenv("APP_DATABASE_URL", raising=False)
with pytest.raises(ValidationError):
Settings(_env_file=None) # no file, no environment: required field missing
def test_environment_beats_the_env_file(monkeypatch, tmp_path):
env_file = tmp_path / ".env"
env_file.write_text("APP_ENVIRONMENT=development\n")
monkeypatch.setenv("APP_ENVIRONMENT", "production")
assert Settings(_env_file=env_file).environment == "production"
def test_no_unknown_app_variables_are_set():
"""The check extra='forbid' cannot do for the process environment."""
known = {f"APP_{name.upper()}" for name in Settings.model_fields}
known |= {f"APP_{n.upper()}__{s.upper()}"
for n, m in Settings.model_fields.items()
if hasattr(m.annotation, "model_fields")
for s in m.annotation.model_fields}
unknown = {k for k in os.environ if k.startswith("APP_")} - known
assert not unknown, f"unknown APP_* variables set: {sorted(unknown)}"
The third test is the one worth adding today. Run it at startup rather than only in CI — as a one-line check inside create_app — and a mistyped variable name in a deployment manifest fails the rollout instead of quietly leaving the field at its default.
Logging the resolved configuration once at boot is the other half. Because SecretStr masks itself, settings.model_dump() is safe to emit, and it answers "what was this pod actually running" without a shell into the container.
Trade-offs and When Not To
Strict typing has a cost at the boundary with operators. A Literal field means an environment nobody anticipated — a new region name, a fourth deployment tier — cannot be introduced without a code change and a release. That is usually the correct trade, but be deliberate about which fields get closed types: constrain the ones where a wrong value is dangerous, and leave descriptive strings open.
The JSON parsing of complex types is the sharpest edge in daily use. Operators reasonably expect APP_FEATURE_FLAGS=a,b,c to work, and it produces a validation error that reads as if the variable were unset. If humans will edit the value, add a mode="before" validator that accepts both spellings rather than teaching everyone the JSON rule.
Finally, lru_cache on the accessor is standard advice and it interacts badly with tests: the first construction is memoised for the process, so a later monkeypatch.setenv has no effect. Either construct Settings directly in tests, as above, or call get_settings.cache_clear() in a fixture. The alternative — no caching at all — reparses and revalidates the whole environment on every request, which is wasted work on a hot path.
FAQ
How does pydantic-settings match an environment variable to a field?
It lowercases the variable name, strips the configured env_prefix, and looks for a field with that name. Matching is case-insensitive by default, so a verified run shows a field named max_connections being populated by a variable spelled app_max_connections in lower case.
Does extra="forbid" catch a misspelled environment variable?
Only in a .env file, not in the process environment. A verified run shows PROBE_NAM in a .env file raising extra_forbidden, while the identical PROBE_NAM exported as a real environment variable is silently ignored. The dotenv source reads every key in the file; the environment source only looks up the fields it knows about.
How do I configure a nested settings model?
Give the parent a nested model field and set env_nested_delimiter, conventionally a double underscore. With env_prefix APP_ and delimiter __, the variables APP_DB__HOST and APP_DB__PORT populate a DatabaseConfig submodel, and any field of that submodel not supplied keeps its own default.
Why does my list field reject a comma-separated value?
Complex types are parsed as JSON, so a list field expects the value to be a JSON array such as ["a","b"] rather than a,b. If operators need comma-separated input, add a field validator with mode="before" that splits the string before validation runs.
Where should Settings be constructed?
Once, at import of a config module, or behind an lru_cache accessor. Constructing it inside a request handler reparses and revalidates the environment on every call, and it delays a misconfiguration error until traffic arrives instead of surfacing it at startup.
Related Reading
- Up to the topic: Configuration Management, for why configuration should be typed and injected at all.
- for choosing the file each environment loads and injecting credentials safely: Secrets and .env Files Per Environment.
- for whether this library is the right one for your service: Pydantic Settings vs Dynaconf vs python-decouple.
- for where
Settingsgets constructed and handed to the application: Application Factory Patterns. - for getting settings into handlers without a global import: Best Practices for FastAPI Dependency Injection.