6.5 KiB
| title | aliases | tags | sources | created | updated | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| pydantic-settings & .env pattern |
|
|
|
2026-05-15 | 2026-05-15 |
Key Takeaways
- Always use
SettingsConfigDict(env_file=".env", extra="ignore")—extra="ignore"prevents crash when.envhas keys not declared in Settings. - Two naming styles in use: SCREAMING_SNAKE (cc-dashboard) and lowercase +
Field(alias=...)(DevOps_Click_UP_sync). Pick one per project and stick to it. SCREAMING_SNAKE is simpler for small configs;aliasis required if field name would clash with a Python keyword or SQLAlchemy reserved attr. - Computed/derived fields go in
model_post_init, not@validator— avoids Pydantic v2 deprecation warnings. - Expose as module-level singleton
settings = Settings(). For FastAPI DI use@lru_cache+get_settings()(Barclays pattern). - Comma-separated list fields (e.g.
ADMIN_EMAILS,CORS_ORIGINS) stay asstrin Settings; parse tolistvia a@property.
Standard Settings Class
Full pattern from cc-dashboard (src/config.py):
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# DB
DB_PASSWORD: str = "postgres"
DATABASE_URL: str = "" # derived in model_post_init if empty
# JWT
SECRET_KEY: str = "dev_secret_key_change_in_production"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# App
BASE_PATH: str = "/cc-dashboard"
DEBUG: bool = False
# Azure AD SSO
AZURE_TENANT_ID: str = ""
AZURE_CLIENT_ID: str = ""
ALLOWED_EMAIL_DOMAIN: str = "oliver.agency"
ADMIN_EMAILS: str = "" # comma-separated lowercase
# Mailgun
MAILGUN_API_KEY: str = ""
MAILGUN_DOMAIN: str = ""
MAILGUN_FROM: str = "CC Dashboard <noreply@example.com>"
# AI
ANTHROPIC_API_KEY: str = ""
def model_post_init(self, __context):
if not self.DATABASE_URL:
object.__setattr__(
self,
"DATABASE_URL",
f"postgresql+asyncpg://cc_app:{self.DB_PASSWORD}@db:5432/cc_dashboard",
)
settings = Settings()
FastAPI DI variant from Barclays-banner-builder (backend/app/config.py):
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = "postgresql+asyncpg://banners:banners_dev@localhost:5432/barclays_banners"
redis_url: str = "redis://localhost:6379/0"
secret_key: str = "dev-secret-key-change-in-prod"
cors_origins: str = "http://localhost:5173,http://localhost:8080"
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@property
def is_dev(self) -> bool:
return self.app_env == "development"
@lru_cache
def get_settings() -> Settings:
return Settings()
Field Patterns
Optional with None default — use when feature is fully disabled:
from typing import Optional
SENTRY_DSN: Optional[str] = None
Required field (no default) — app crashes at startup if missing from env, which is the desired behaviour for secrets:
SECRET_KEY: str # no default → required
Field with alias — use when field name conflicts with builtins or SQLAlchemy attrs (DevOps_Click_UP_sync pattern):
from pydantic import Field
ado_pat: str = Field(default="", alias="ADO_PAT")
model_config = {"env_file": ".env", "populate_by_name": True}
# populate_by_name=True allows both ado_pat and ADO_PAT in code
Derived URL from parts (cc-dashboard pattern):
def model_post_init(self, __context):
if not self.DATABASE_URL:
object.__setattr__(self, "DATABASE_URL",
f"postgresql+asyncpg://app:{self.DB_PASSWORD}@db:5432/appdb")
Comma-list as property (Barclays pattern):
ADMIN_EMAILS: str = ""
@property
def admin_email_list(self) -> list[str]:
return [e.strip().lower() for e in self.ADMIN_EMAILS.split(",") if e.strip()]
.env.example Pattern
Always commit .env.example, never .env. Template used across Oliver projects:
# Database
DB_PASSWORD=postgres
DATABASE_URL= # leave empty → auto-built from DB_PASSWORD
# Security
SECRET_KEY=dev_secret_key_change_in_production
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
# Azure AD SSO (required in production)
AZURE_TENANT_ID=
AZURE_CLIENT_ID=
ALLOWED_EMAIL_DOMAIN=oliver.agency
ADMIN_EMAILS= # comma-separated: admin@oliver.agency,other@oliver.agency
# Mailgun (empty → email disabled)
MAILGUN_API_KEY=
MAILGUN_DOMAIN=
MAILGUN_FROM=App <noreply@example.com>
# AI
ANTHROPIC_API_KEY=
# Dev only
DEV_AUTH_BYPASS=false
DEBUG=false
LOG_FORMAT=console # console | json
Gotchas
1. model is a reserved field name in Pydantic v2.
If you name a settings field model (e.g. openai_model) it silently overwrites the internal model attribute. Use a prefixed name:
# BAD
model: str = "gpt-4"
# GOOD
openai_model: str = "gpt-4"
2. Env var case sensitivity on Linux.
SettingsConfigDict reads env vars case-insensitively by default in pydantic-settings, but Docker environment: keys are case-sensitive at the OS level. A field declared DATABASE_URL will match env var database_url on Linux — but only inside the pydantic layer. External tools (healthchecks, entrypoint scripts) that read $DATABASE_URL require exact case. Standardise on SCREAMING_SNAKE in both code and compose files.
3. lru_cache + test isolation.
@lru_cache on get_settings() means tests share the same Settings instance across the session. Override with:
app.dependency_overrides[get_settings] = lambda: Settings(SECRET_KEY="test", ...)
Or call get_settings.cache_clear() in a fixture teardown.
4. extra="ignore" masks typos.
A misspelled env var in .env (e.g. MAILGN_API_KEY) will silently be ignored. If startup validation is critical, use extra="forbid" in dev and extra="ignore" in prod, controlled by an env flag.