Chapter 9: pydantic-settings. Typed config from env vars, .env files, secrets, with the same Pydantic ergonomics.
Setup
uv add pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str
secret_key: str
log_level: str = "INFO"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_prefix="MYAPP_",
case_sensitive=False,
)
settings = Settings()
Reads MYAPP_DATABASE_URL, MYAPP_SECRET_KEY, etc.
.env files
# .env
MYAPP_DATABASE_URL=postgresql://...
MYAPP_SECRET_KEY=...
Loaded automatically. Don’t commit production .env; do commit .env.example.
Nested settings
class DBSettings(BaseSettings):
url: str
pool_size: int = 10
model_config = SettingsConfigDict(env_prefix="MYAPP_DB_")
class Settings(BaseSettings):
db: DBSettings = DBSettings()
secret_key: str
model_config = SettingsConfigDict(env_prefix="MYAPP_")
Access: settings.db.url.
For deeper nesting: __ separator:
MYAPP_DB__URL=postgresql://...
class Settings(BaseSettings):
db: DBSettings
model_config = SettingsConfigDict(env_nested_delimiter="__")
Secrets
For Docker secrets (/run/secrets/...):
class Settings(BaseSettings):
secret_key: str
model_config = SettingsConfigDict(secrets_dir="/run/secrets")
Looks for /run/secrets/secret_key (lowercase by default).
Multiple .env files
model_config = SettingsConfigDict(env_file=(".env", ".env.local"))
Later files override earlier. .env.local for dev overrides; .env for shared defaults.
Sources priority
Default priority (highest first):
- CLI args (with cli_parse_args).
- Init kwargs.
- Environment vars.
- Dotenv file.
- Secrets directory.
- Default values.
Customizable:
class Settings(BaseSettings):
@classmethod
def settings_customise_sources(cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings):
return env_settings, init_settings, dotenv_settings, file_secret_settings
# custom order
CLI integration
class Settings(BaseSettings):
database_url: str
debug: bool = False
model_config = SettingsConfigDict(cli_parse_args=True)
# python myapp.py --database-url=... --debug
CLI flags from field names. Underscores → hyphens.
For argparse-like control:
model_config = SettingsConfigDict(
cli_parse_args=True,
cli_prog_name="myapp",
cli_use_class_docs_for_groups=True,
)
TOML / YAML / JSON
from pydantic_settings import BaseSettings, TomlConfigSettingsSource
class Settings(BaseSettings):
model_config = SettingsConfigDict(toml_file="config.toml")
@classmethod
def settings_customise_sources(cls, settings_cls, *args):
return (TomlConfigSettingsSource(settings_cls),) + args
Similar for YAML / JSON. Useful for non-env configurations.
With FastAPI
from functools import lru_cache
from fastapi import Depends
@lru_cache
def get_settings() -> Settings:
return Settings()
@app.get("/info")
async def info(settings: Settings = Depends(get_settings)):
return {"db": settings.database_url[:20] + "..."}
lru_cache: instantiate once. See FastAPI DI chapter
.
Validation on startup
try:
settings = Settings()
except ValidationError as e:
print(e)
sys.exit(1)
Fail fast. Don’t run with bad config.
For multiple errors: pydantic shows them all at once.
Sensitive fields
from pydantic import SecretStr
class Settings(BaseSettings):
api_key: SecretStr
db_password: SecretStr
print(settings) # api_key=SecretStr('**********')
print(settings.api_key.get_secret_value()) # actual
SecretStr prevents accidental logging. Repr shows asterisks.
Frozen settings
class Settings(BaseSettings):
model_config = SettingsConfigDict(frozen=True)
Immutable after creation.
Per-environment configs
class Settings(BaseSettings):
env: Literal["dev", "staging", "prod"] = "dev"
database_url: str
@model_validator(mode="after")
def validate_prod(self):
if self.env == "prod" and "localhost" in self.database_url:
raise ValueError("prod can't use localhost DB")
return self
Common mistakes
1. Settings() without lru_cache
Reads .env every call; instantiates Settings. Slow.
2. Plaintext secrets in .env in git
Add .env to .gitignore. Commit .env.example.
3. Validation errors at runtime
Settings only validate on instantiation. Make sure to instantiate at startup.
4. Mixing env_prefix conventions
MYAPP_DB_URL vs MYAPP__DB_URL vs MYAPP_DB__URL. Pick a convention.
5. SecretStr leaking
json.dumps(settings.dict()) doesn’t redact. Use model_dump(mode="python") and check.
What’s next
Chapter 10: Performance, FastAPI integration, alternatives.
Read this next
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .