---
title: pydantic-settings & .env pattern
aliases:
  - BaseSettings
  - pydantic env config
tags:
  - python
  - pydantic
  - config
  - shared-pattern
sources:
  - cc-dashboard/src/config.py
  - Barclays-banner-builder/backend/app/config.py
  - DevOps_Click_UP_sync/src/config.py
created: 2026-05-15
updated: 2026-05-15
---

## Key Takeaways

- Always use `SettingsConfigDict(env_file=".env", extra="ignore")` — `extra="ignore"` prevents crash when `.env` has 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; `alias` is 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 as `str` in Settings; parse to `list` via a `@property`.

## Standard Settings Class

Full pattern from **cc-dashboard** (`src/config.py`):

```python
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`):

```python
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:
```python
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:
```python
SECRET_KEY: str  # no default → required
```

**Field with alias** — use when field name conflicts with builtins or SQLAlchemy attrs (DevOps_Click_UP_sync pattern):
```python
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):
```python
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):
```python
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:

```dotenv
# 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:
```python
# 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:
```python
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.

## Related

- [[wiki/tech-patterns/fastapi-python-docker]]
- [[wiki/shared-patterns/alembic-migrations]]
