Application Settings — pydantic-settings and Environment Config

Every FastAPI application needs configuration — database URL, secret keys, allowed CORS origins, debug flags, and feature toggles. Hard-coding these values creates security risks and makes the application impossible to deploy across environments (development, staging, production). pydantic-settings solves this with a BaseSettings class that reads configuration from environment variables and .env files, validates and types every setting, and makes the configuration available as a dependency-injected singleton. The result is a fully type-safe, self-documenting configuration system that works correctly across all deployment environments.

Defining Application Settings

# app/config.py
from pydantic_settings import BaseSettings
from pydantic import AnyHttpUrl, PostgresDsn, Field
from typing import Literal
from functools import lru_cache

class Settings(BaseSettings):
    # ── Application ───────────────────────────────────────────────────────────
    app_name:    str = "Blog API"
    environment: Literal["development", "staging", "production"] = "development"
    debug:       bool = False
    api_v1_prefix: str = "/api/v1"

    # ── Security (REQUIRED — no default → must be in env) ────────────────────
    secret_key:  str   # no default — MUST be set in environment
    algorithm:   str = "HS256"
    access_token_expire_minutes:  int = Field(default=30,  ge=1)
    refresh_token_expire_days:    int = Field(default=7,   ge=1)

    # ── Database ──────────────────────────────────────────────────────────────
    database_url: PostgresDsn   # validated as postgresql:// URL
    db_pool_size: int = Field(default=10, ge=1, le=50)
    db_echo_sql:  bool = False   # log all SQL — useful in development

    # ── CORS ──────────────────────────────────────────────────────────────────
    allowed_origins: list[AnyHttpUrl] = ["http://localhost:5173"]

    # ── File uploads ──────────────────────────────────────────────────────────
    upload_dir:    str = "uploads"
    max_upload_mb: int = Field(default=10, ge=1, le=100)

    model_config = {
        "env_file":          ".env",
        "env_file_encoding": "utf-8",
        "case_sensitive":    False,   # DATABASE_URL and database_url both work
        "extra":             "ignore", # ignore unknown env vars
    }

# ── Cached singleton ──────────────────────────────────────────────────────────
@lru_cache
def get_settings() -> Settings:
    return Settings()

# Convenience: import and use directly
settings = get_settings()
Note: @lru_cache on get_settings() ensures the Settings object is created only once — the .env file is read once at startup, not on every request. This is important because reading and validating the settings file has non-trivial overhead. Without @lru_cache, every request that depends on settings would re-read the .env file. The lru_cache stores the result after the first call and returns the cached object for all subsequent calls.
Tip: Use pydantic-settings’ PostgresDsn type for database URLs — it validates that the URL matches the PostgreSQL scheme and has the required components (host, database name). It also provides convenient access to individual components: settings.database_url.host, settings.database_url.path. For other URLs, use AnyHttpUrl (requires http:// or https://) or AnyUrl (any scheme). These types catch configuration errors at startup rather than at runtime when the first database query fails.
Warning: Never commit .env files to version control. Add .env to .gitignore. Commit only a .env.example file with placeholder values: SECRET_KEY=change-me-in-production, DATABASE_URL=postgresql://user:pass@localhost/dbname. In production, set environment variables directly in the deployment environment (Docker Compose environment section, Kubernetes Secrets, cloud provider secrets manager) rather than using a .env file.

Environment-Specific Settings

# .env.example  (commit this to git — no real secrets)
APP_NAME=Blog API
ENVIRONMENT=development
DEBUG=true
SECRET_KEY=change-me-in-production-use-256-bit-random-string
DATABASE_URL=postgresql://blog_app:devpassword@localhost:5432/blog_dev
DB_ECHO_SQL=true
ALLOWED_ORIGINS=["http://localhost:5173","http://localhost:3000"]
UPLOAD_DIR=uploads
MAX_UPLOAD_MB=10

# .env  (never commit — real development secrets)
SECRET_KEY=your-local-dev-secret-key-here
DATABASE_URL=postgresql://blog_app:devpassword@localhost:5432/blog_dev

Using Settings in FastAPI

from fastapi import FastAPI, Depends
from functools import lru_cache
from app.config import Settings, get_settings

app = FastAPI()

# ── Inject settings as a dependency ───────────────────────────────────────────
@app.get("/info")
def app_info(settings: Settings = Depends(get_settings)):
    return {
        "app_name":    settings.app_name,
        "environment": settings.environment,
        "version":     "1.0.0",
        # Never expose secrets: no secret_key, no database_url!
    }

# ── Use settings at startup ───────────────────────────────────────────────────
from sqlalchemy import create_engine
from app.config import settings

engine = create_engine(
    str(settings.database_url),    # convert PostgresDsn to string
    pool_size = settings.db_pool_size,
    echo      = settings.db_echo_sql,
)

# ── Environment-specific behaviour ───────────────────────────────────────────
from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    s = get_settings()
    if s.environment == "development":
        print(f"Starting {s.app_name} in DEVELOPMENT mode")
        print(f"DB: {s.database_url.host}/{s.database_url.path}")
    yield

Testing with Settings Override

# tests/conftest.py
from fastapi.testclient import TestClient
from app.main import app
from app.config import get_settings, Settings

def override_settings():
    return Settings(
        environment = "testing",
        database_url = "postgresql://user:pass@localhost/test_db",
        secret_key   = "test-secret-key",
    )

app.dependency_overrides[get_settings] = override_settings

client = TestClient(app)

Common Mistakes

Mistake 1 — Committing .env with real secrets

❌ Wrong — real secrets in version control:

git add .env   # NEVER do this — exposes database passwords, secret keys!

✅ Correct — .gitignore includes .env; only .env.example is committed.

Mistake 2 — Not caching Settings (re-reads .env on every request)

❌ Wrong — new Settings() on every request:

@app.get("/posts")
def list_posts(settings: Settings = Depends(Settings)):   # new instance per request!

✅ Correct — use lru_cache dependency:

@app.get("/posts")
def list_posts(settings: Settings = Depends(get_settings)):   # ✓ cached singleton

Mistake 3 — Exposing secrets in API responses

❌ Wrong — returning settings object with secret_key:

@app.get("/debug")
def debug(settings: Settings = Depends(get_settings)):
    return settings.model_dump()   # includes secret_key, database_url! SECURITY RISK!

✅ Correct — return only non-sensitive settings:

return {"app_name": settings.app_name, "environment": settings.environment}   # ✓

Quick Reference

Task Code
Define settings class Settings(BaseSettings): field: type = default
Read .env file model_config = {"env_file": ".env"}
Cache singleton @lru_cache def get_settings() -> Settings:
Inject as dep settings: Settings = Depends(get_settings)
Required setting No default value — raises error if missing
PostgreSQL URL type database_url: PostgresDsn
Override in tests app.dependency_overrides[get_settings] = override_fn

🧠 Test Yourself

Your Settings class has secret_key: str with no default value. You start the application without setting the SECRET_KEY environment variable. What happens?