Cheatsheet for typed config via pydantic-settings.
Install
uv add pydantic-settings
Basic
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 file
# .env (don't commit)
MYAPP_DATABASE_URL=postgresql://...
MYAPP_SECRET_KEY=...
MYAPP_LOG_LEVEL=DEBUG
Commit .env.example instead.
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_")
MYAPP_DB_URL=postgresql://...
MYAPP_DB_POOL_SIZE=20
MYAPP_SECRET_KEY=...
Nested via delimiter
class Settings(BaseSettings):
db: DBSettings
model_config = SettingsConfigDict(env_nested_delimiter="__")
MYAPP_DB__URL=postgresql://...
Secrets directory (Docker 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.
Source priority
Default (highest first):
- CLI args (
cli_parse_args=True). Settings(...)kwargs.- Environment vars.
- Dotenv file.
- Secrets directory.
- Defaults.
SecretStr 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 value
Prevents accidental log leaks.
CLI integration
class Settings(BaseSettings):
database_url: str
debug: bool = False
model_config = SettingsConfigDict(cli_parse_args=True)
# python myapp.py --database-url=... --debug
For complex CLI: typer or click; for config: pydantic-settings.
TOML / YAML / JSON sources
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 YamlConfigSettingsSource, JsonConfigSettingsSource.
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(s: Settings = Depends(get_settings)):
return {"db": s.database_url[:20] + "..."}
lru_cache ensures single Settings instance.
Per-env config
class Settings(BaseSettings):
env: Literal["dev", "staging", "prod"] = "dev"
database_url: str
@model_validator(mode="after")
def prod_safety(self):
if self.env == "prod" and "localhost" in self.database_url:
raise ValueError("prod can't use localhost DB")
return self
Type validation at startup
try:
settings = Settings()
except ValidationError as e:
print(e)
sys.exit(1)
Fail fast. Don’t start with bad config.
Frozen settings
class Settings(BaseSettings):
model_config = SettingsConfigDict(frozen=True)
Immutable after creation.
Common mistakes
- Plaintext .env in git — add to .gitignore; commit .env.example.
- Settings() without lru_cache — reads .env every call.
- Forgetting
case_sensitive=False—DATABASE_URLvsdatabase_urlmismatch. - SecretStr in logs without redaction —
print(settings)works (redacted);model_dump()doesn’t. - Mixed env_prefix conventions across services.
Read this next
If you want my Settings template with secrets + validation, it’s at rajpoot.dev .
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 .