Settings and Environments (pydantic-settings)

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.

Defining Settings

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"]

Injecting Settings via Dependencies

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}

Multiple Environments

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
Never commit secrets. Add .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.

Quick Questions and Answers

Q: Why did my list-valued env var fail to parse?

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.

Q: Settings vs constants module — why the ceremony?

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.

Q: Can I have per-environment Settings classes (Dev/Prod subclasses)?

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.

Conclusion

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.