Environment and Settings Management
Hardcoded configuration is a security and deployment anti-pattern. pydantic-settings provides type-safe, validated settings from environment variables and .env files — with the same Pydantic model ergonomics you already know.
Learning Focus
By the end of this lesson you can: define validated settings with BaseSettings, load from .env files, use environment-specific overrides, and access settings safely throughout your app.
pydantic-settings Setup
install-settings.sh
pip install pydantic-settings
Full Settings Class
app/core/config.py
from pydantic import field_validator, PostgresDsn, RedisDsn, AnyHttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Literal
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False, # DB_URL and db_url both work
extra="ignore", # Ignore unknown env vars
)
# Application
APP_NAME: str = "My FastAPI App"
APP_VERSION: str = "1.0.0"
ENV: Literal["development", "staging", "production"] = "development"
DEBUG: bool = False
SECRET_KEY: str
LOG_LEVEL: str = "info"
# Database
DATABASE_URL: str # postgresql+asyncpg://user:pass@host/db
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
# Auth
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ALGORITHM: str = "HS256"
# CORS
ALLOWED_ORIGINS: list[str] = ["http://localhost:3000"]
# Optional external services
STRIPE_SECRET_KEY: str | None = None
SENDGRID_API_KEY: str | None = None
@field_validator("SECRET_KEY")
@classmethod
def secret_key_must_be_long(cls, v: str) -> str:
if len(v) < 32:
raise ValueError("SECRET_KEY must be at least 32 characters")
return v
@field_validator("ALLOWED_ORIGINS", mode="before")
@classmethod
def parse_origins(cls, v):
if isinstance(v, str):
return [o.strip() for o in v.split(",") if o.strip()]
return v
@property
def is_production(self) -> bool:
return self.ENV == "production"
@property
def is_debug(self) -> bool:
return self.DEBUG and not self.is_production
settings = Settings()
Environment Files
.env.example
# Copy to .env and fill in values
APP_NAME=My FastAPI App
ENV=development
DEBUG=true
SECRET_KEY=generate-with-python-secrets-token-hex-32
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/app
REDIS_URL=redis://localhost:6379/0
ACCESS_TOKEN_EXPIRE_MINUTES=30
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
.env.production
ENV=production
DEBUG=false
SECRET_KEY=<generated-32-byte-secret>
DATABASE_URL=postgresql+asyncpg://app_user:${DB_PASS}@db.prod.internal/app
REDIS_URL=redis://:${REDIS_PASS}@cache.prod.internal:6379/0
ALLOWED_ORIGINS=https://app.example.com
Using Settings in the App
app/main.py
from app.core.config import settings
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
debug=settings.DEBUG,
docs_url="/docs" if not settings.is_production else None,
)
Injecting Settings as a Dependency
app/dependencies/settings.py
from functools import lru_cache
from typing import Annotated
from fastapi import Depends
from app.core.config import Settings
@lru_cache
def get_settings() -> Settings:
return Settings()
SettingsDep = Annotated[Settings, Depends(get_settings)]
app/routers/info.py
@router.get("/info")
async def api_info(settings: SettingsDep) -> dict:
return {
"name": settings.APP_NAME,
"version": settings.APP_VERSION,
"env": settings.ENV,
}
Using lru_cache means Settings() is instantiated once — the Depends pattern allows overriding in tests.
Testing Settings Override
tests/conftest.py
from app.dependencies.settings import get_settings
from app.core.config import Settings
from app.main import app
def get_test_settings():
return Settings(
SECRET_KEY="test-secret-key-32-characters-long",
DATABASE_URL="sqlite+aiosqlite:///:memory:",
ENV="development",
DEBUG=True,
)
app.dependency_overrides[get_settings] = get_test_settings
Common Pitfalls
| Pitfall | Cause / Symptom | Fix |
|---|---|---|
.env not loaded | File path wrong or encoding issue | Set env_file=".env" and env_file_encoding="utf-8" |
| Secret validated at import time | Settings instantiated at module level | Wrap in lru_cache function and use Depends |
DEBUG=true in production | Forgotten flag enables verbose errors | Validate DEBUG is False when ENV=production |
| Settings object shared across threads | Mutable settings state | Make Settings frozen with model_config = ConfigDict(frozen=True) |
| Missing required env var crashes at startup | No default for required field | Always test startup in CI with production-like env vars |
Hands-On Practice
settings-practice.sh
# Generate a secure secret key
python -c "import secrets; print(secrets.token_hex(32))"
# Create .env
cat > .env << 'EOF'
SECRET_KEY=<paste-generated-key-here>
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/myapp
ENV=development
DEBUG=true
ALLOWED_ORIGINS=http://localhost:3000
EOF
# Verify settings load correctly
python -c "from app.core.config import settings; print(settings.model_dump())"