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()
@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.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..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 |