Skip to main content

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

PitfallCause / SymptomFix
.env not loadedFile path wrong or encoding issueSet env_file=".env" and env_file_encoding="utf-8"
Secret validated at import timeSettings instantiated at module levelWrap in lru_cache function and use Depends
DEBUG=true in productionForgotten flag enables verbose errorsValidate DEBUG is False when ENV=production
Settings object shared across threadsMutable settings stateMake Settings frozen with model_config = ConfigDict(frozen=True)
Missing required env var crashes at startupNo default for required fieldAlways 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())"

What's Next