Configuration (DB URLs, secrets, feature flags) must come from the environment, not from code. pydantic-settings gives you typed, validated settings loaded from environment variables and .env files — the official FastAPI recommendation.
pip install pydantic-settings
# core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
app_name: str = "Inventory API"
environment: str = "dev" # dev | staging | prod
debug: bool = False
database_url: str
secret_key: str
access_token_expire_minutes: int = 30
cors_origins: list[str] = []
# .env (NEVER commit this file)
DATABASE_URL=postgresql://user:pass@localhost:5432/appdb
SECRET_KEY=09d25e094faa6ca2556c818166b7a956...
CORS_ORIGINS=["http://localhost:3000","https://mysite.com"]
debug=true → True; JSON lists/dicts parse from strings).database_url) fail fast at startup if missing — much better than a KeyError at 3 a.m..env file > defaults.from functools import lru_cache
from fastapi import Depends, FastAPI
from core.config import Settings
app = FastAPI()
@lru_cache
def get_settings() -> Settings:
return Settings()
@app.get("/info/")
def info(settings: Settings = Depends(get_settings)):
return {"app": settings.app_name, "env": settings.environment}
@lru_cache reads/parses the environment once; without it, every request would re-read.Settings as a dependency (instead of importing a global) keeps tests trivial: app.dependency_overrides[get_settings] = lambda: Settings(secret_key="test", database_url="sqlite://").class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
environment: str = "dev"
database_url: str
echo_sql: bool = False
@property
def is_prod(self) -> bool:
return self.environment == "prod"
# deploy: dev uses .env, prod gets variables from the platform
ENVIRONMENT=prod DATABASE_URL=... uvicorn app.main:app --workers 4
docs_url=None if settings.is_prod else "/docs"), SQL echo only in dev, stricter CORS origins..env to .gitignore immediately, commit a .env.example with empty values instead, and treat any secret that ever touched git history as compromised (rotate it). Use SecretStr as the type for sensitive fields so they are not printed in logs/repr.
A: For complex types (list, dict) pydantic-settings expects JSON in the environment: CORS_ORIGINS=["http://a.com"]. Alternatively declare the field as str and split it yourself, or use an env_parse_none_str/NoDecode customization.
A: A constants.py hard-codes values into the build and is identical in every environment. BaseSettings validates types at boot, fails fast on missing config, reads 12-factor style from the environment, and plugs into dependency_overrides for tests.
A: Yes — a common pattern is a base Settings plus DevSettings/ProdSettings chosen by an APP_ENV variable in a get_settings() factory. Keep it simple though: different values via environment variables usually beats different classes.
pydantic-settings makes configuration a typed, validated input of your app: define the schema once, supply values per environment, cache with lru_cache, and override in tests. Combined with SecretStr and a good secrets manager, this closes the "config" layer of a production service.