Scattered os.getenv() calls produce unvalidated strings and late failures. pydantic-settings centralizes options in a typed model, converts environment values, and stops startup when required configuration is missing.
It builds on Pydantic but is installed separately:
python -m pip install pydantic-settings
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
debug: bool = False
database_url: str
api_key: SecretStr
workers: int = 2
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="APP_",
extra="ignore",
)
The model expects names such as APP_DATABASE_URL. Invalid booleans or integers raise validation errors. Add .env to .gitignore and commit only .env.example without credentials. SecretStr reduces accidental display but does not encrypt a value or replace a secret manager.
Prefer one model with different deployment values over divergent environment-specific classes when only values change. Production secrets should come from the deployment platform or a vault. The official settings documentation covers source priority and nested values.
Load settings at an explicit boundary and inject them. In FastAPI, dependency injection makes test overrides straightforward.
Never log secrets, validate operational limits, document .env lookup, and fail at startup for missing required values. Typed configuration discovers bad deployments before they process requests.
Understand source priority
By default, constructor arguments take priority over environment variables, which take priority over dotenv values and then secrets files. Model defaults come last. That order lets a test override an option without mutating the process environment:
test_settings = Settings(
database_url="sqlite://",
api_key="test-key",
workers=1,
)
Do not expect .env to replace a variable already exported by the process. The environment normally wins. That is useful in containers and CI, but it can surprise developers during diagnosis. Log selected non-sensitive options and document their expected source, never credential contents.
Dotenv loading does not recursively search parent directories. A relative path depends on the process working directory. For a service, either construct a known path from the application location or let the deployment platform provide all values.
Nested models and complex values
Lists, dictionaries, and nested models can be supplied as JSON strings. A nested delimiter lets separate environment variables override parts of a structure:
from pydantic import BaseModel
class Database(BaseModel):
host: str
port: int = 5432
pool_size: int = 10
class Settings(BaseSettings):
database: Database
model_config = SettingsConfigDict(
env_prefix="APP_",
env_nested_delimiter="__",
)
Now APP_DATABASE__HOST=db.internal and APP_DATABASE__POOL_SIZE=20 populate inner fields. Exact casing behavior depends on configuration and the operating system, so standardize names and test on the same kind of environment used for deployment.
Use Pydantic validators for additional constraints. A timeout can require a positive value, and production may forbid debug mode. Keep settings validation deterministic and free of network calls. Creating the model should report invalid configuration, not depend on the temporary availability of another service.
Prefixes, aliases, and legacy names
An env_prefix avoids collisions when multiple applications share an environment. Aliases can bridge existing names, but validation aliases, serialization aliases, and environment lookup have distinct purposes. Confirm behavior against the installed pydantic-settings version and test the actual variable name.
Do not accept multiple names indefinitely. During a migration, support the legacy name explicitly, inform operators, and set a removal point. Silent ambiguity makes incidents difficult to diagnose.
Treat capitalization as part of the operational contract on case-sensitive platforms. Development behavior on Windows should not be the only evidence for a Linux deployment.
What SecretStr does and does not protect
SecretStr masks content in repr() and common serialization, but the value remains in memory and is available through get_secret_value(). Reveal it only at the integration boundary:
client = ExternalClient(
token=settings.api_key.get_secret_value()
)
Do not dump the entire settings object to logs. An explicit allowlist of public fields is safer. Secrets directories mounted by an orchestrator can act as a source, but permissions, rotation, and persistence belong to the platform. Pydantic validates and exposes a value; it does not manage its lifecycle.
Be cautious with secrets that are optional only in development. A str | None field may let production start without protection. A required field plus an isolated test value is often safer.
Load once without hiding dependencies
Repeated construction rereads sources and repeats validation. A long-running application can load settings once or cache a composition function:
from functools import lru_cache
@lru_cache
def get_settings() -> Settings:
return Settings()
Consumers should still receive Settings, or a smaller configuration section, as an argument. Importing a global singleton throughout the codebase hides dependencies and complicates tests. After modifying the environment in a test, clear the cache, or preferably pass a directly constructed instance.
Reusable libraries should not read environment variables at import time. Accept options from the host application. This avoids side effects and allows two clients of the same library to use different configurations.
Report startup errors safely
Catch ValidationError at the application entry point, render an actionable message, and exit with a nonzero status. Do not continue with partial configuration. Invalid input may itself contain sensitive data, so prefer field names and expected types over printing every received value.
Test missing required fields, successful conversions, out-of-range values, and source precedence. In pytest, use monkeypatch.setenv() and monkeypatch.delenv() to isolate the environment, then instantiate the model. Developer machines and CI runners may contain unexpected variables, so explicitly remove every name that affects the scenario.
Finally, treat configuration as a versioned interface between application and operations. Renaming an environment variable can break deployment even when unit tests pass. Document names, types, required status, safe examples, and migration policy alongside the project.
Separate operational domains
A large application does not need one flat model containing every option. Compose nested models for database, email, observability, and external clients, then inject only the section a component needs. This reduces accidental access to unrelated secrets and makes tests easier to read.
Defaults deserve review. A harmless display name can have a default; a database address, encryption key, or production hostname often should not. A convenient fallback may silently connect a deployed service to localhost or disable protection. Decide defaults from failure consequences, not from typing convenience.
Add a startup smoke test in the deployment process that creates the model using the real injection mechanism without printing values. This catches misspelled names and missing mounts before traffic arrives. When configuration changes, update the safe example and deployment documentation in the same change, while keeping real credentials outside version control.
For diagnosis, report the field name and violated constraint while redacting the supplied value. An actionable error should help an operator correct the deployment without copying tokens into logs, alerts, or support systems.
Keep that output concise.