pydantic-settings
pydantic-settings extends Pydantic models to load configuration from environment variables, .env files, and optional secrets directories - with validation, type coercion, and clear error messages at startup.
Recipe
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = Field(alias="DATABASE_URL")
debug: bool = False
api_key: str
settings = Settings() # raises ValidationError if api_key missingWhen to reach for this:
- FastAPI/Django/Flask apps reading
os.environ - Workers and CLIs that need typed, documented config
- Replacing manual
os.getenv("PORT", "8000")casts - Validating config before forked Gunicorn workers start
Working Example
from __future__ import annotations
from functools import lru_cache
from pydantic import Field, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "billing-api"
database_url: PostgresDsn = Field(alias="DATABASE_URL")
redis_url: str = Field(default="redis://localhost:6379/0", alias="REDIS_URL")
log_level: str = "INFO"
@lru_cache
def get_settings() -> Settings:
return Settings()
def main() -> None:
s = get_settings()
print(s.app_name, s.database_url, s.log_level)
if __name__ == "__main__":
main()What this demonstrates:
SettingsConfigDictcentralizes env file and ignore-unknown-keys policyField(alias=...)maps screaming-snake env names to snake_case attrsPostgresDsnvalidates connection string shape early@lru_cachegives FastAPI-style singleton settings without globals
Deep Dive
How It Works
- Source priority - Defaults < env file < real environment (env wins over file).
- Type coercion -
"true"becomesTruefor bool fields; comma-separated strings can become lists with custom parsers. - Nested settings -
env_nested_delimiter="__"mapsDB__HOSTtodb.host. - Secrets -
secrets_dirloads Docker/Kubernetes mounted secret files as field values.
Common Fields
| Field Type | Env Example | Notes |
|---|---|---|
str | API_KEY=abc | Required if no default |
bool | DEBUG=true | Accepts true/false/1/0 |
int | PORT=8000 | Invalid values raise at startup |
list[str] | ORIGINS=a,b | Use NoDecode + custom validator for CSV |
SecretStr | TOKEN=... | Masks value in repr/logs |
Python Notes
from pydantic import SecretStr
class Settings(BaseSettings):
token: SecretStr
# SecretStr hides in logs
print(settings.token.get_secret_value()) # explicit revealGotchas
- Import-time side effects -
settings = Settings()at module level fails in tests. Fix: factory +lru_cacheor dependency injection. extra="allow"in production - Typos in env names silently create attributes. Fix:extra="ignore"or"forbid".- Committing
.envwith secrets - Git history leaks credentials. Fix:.env.exampleonly; load real values from platform secrets. - One Settings for monorepo - Couples unrelated services. Fix:
ApiSettings,WorkerSettingsper deployable. - Missing env in Docker - Empty string may coerce unexpectedly for optional ints. Fix: use
Optional[int] = Noneand validate. - Case sensitivity - Env keys are case-sensitive on Linux. Fix: document exact names in
.env.example.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
os.environ + manual casts | Tiny scripts with 2-3 vars | More than a handful of settings |
python-decouple | Legacy Django projects already using it | Greenfield Pydantic/FastAPI stacks |
dynaconf | Multi-environment YAML/TOML layering | You want Pydantic types end-to-end |
django-environ | Django-only configuration | FastAPI or standalone workers |
FAQs
How do I test with different env values?
Clear the cache and pass env via monkeypatch.setenv before calling get_settings() - or use Settings(_env_file=None, api_key="test") constructor overrides.
Can I load multiple env files?
Pass env_file=(".env", ".env.local") - later files do not override earlier unless real env vars win.
How do nested settings work?
Define a nested BaseModel field and set env_nested_delimiter="__" so CACHE__TTL=300 maps correctly.
Does this work with FastAPI Depends?
Yes - def settings_dep() -> Settings: return get_settings() and inject into routes.
How do I validate mutually exclusive flags?
Use @model_validator(mode="after") to raise if both USE_SQLITE and DATABASE_URL are set.
What about 12-factor PORT binding?
port: int = Field(default=8000, alias="PORT") - Heroku/Fly/Railway inject PORT automatically.
Can I use TOML instead of env?
pydantic-settings focuses on env; for TOML-heavy apps combine with pyproject.toml section loaders or dynaconf.
How do I document settings for operators?
Generate JSON schema from the model or maintain .env.example with every key and sample value.
SecretStr vs plain str?
Use SecretStr for tokens and passwords so accidental logging does not print secrets.
Does pydantic-settings work with Django?
Yes in standalone scripts and ASGI workers; Django projects often use django-environ but Pydantic settings work for Celery workers sharing the same env.
Related
- Pydantic Basics - models and validation
- Configuration and Settings - architecture patterns
- FastAPI Basics - dependency injection
- Secrets Management - production secret handling
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.