The same image runs in dev, staging and production with different environment, and bad config fails at startup instead of mid-request.
Hard-coded secrets and URLs are a classic production incident. The standard FastAPI answer is pydantic-settings, a separate package since Pydantic v2 (pip install pydantic-settings; fastapi[standard] doesn't include it). Subclass BaseSettings and declare typed fields with defaults:
- Each field is read from an environment variable of the same name, case-insensitively:
database_urlcomes fromDATABASE_URL. - Values are validated and converted like any model:
debug: boolaccepts1ortrue, andallowed_origins: list[str]parses a JSON array string. model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")also reads a.envfile and namespaces the variables. Real environment variables win over the file.- Fields without defaults are required. A missing
APP_SECRET_KEYraises a validation error the momentSettings()is created, so the app fails fast instead of at the first login. SecretStrmasks the value in logs and reprs; call.get_secret_value()when you really need it.
Expose settings through a dependency wrapped in functools.lru_cache, so the environment and .env file are read once rather than on every request:
@lru_cache
def get_settings() -> Settings:
return Settings()Endpoints take settings: Annotated[Settings, Depends(get_settings)], and tests swap values with app.dependency_overrides[get_settings] = lambda: Settings(debug=True, secret_key="test"). A module-level settings = Settings() also works and is common; the dependency version is easier to override and doesn't read the environment at import time.
Never commit .env. Commit a .env.example that lists every variable with a dummy value.
Example
import os
from functools import lru_cache
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
app_name: str = "Inventory"
debug: bool = False
database_url: str = "sqlite:///./app.db"
secret_key: SecretStr # required: APP_SECRET_KEY
@lru_cache
def get_settings() -> Settings:
return Settings()
app = FastAPI()
@app.get("/info")
def info(settings: Annotated[Settings, Depends(get_settings)]):
return {"name": settings.app_name, "debug": settings.debug}
os.environ.update(APP_SECRET_KEY="dev-only", APP_DEBUG="1")
print(TestClient(app).get("/info").json()) # {'name': 'Inventory', 'debug': True}
print(repr(get_settings().secret_key)) # SecretStr('**********')Remember
- BaseSettings fields come from environment variables (and optionally a .env file)
- Values are validated; required fields without values fail at startup
- Serve settings via an lru_cache'd dependency so tests can override them
- Use SecretStr for secrets and never commit .env
Watch out for
- Forgetting that pydantic-settings is a separate install; BaseSettings no longer lives in pydantic itself.