pydantic-settings reads validated app settings from environment variables and .env files. pip install pydantic-settings. Basic: from pydantic_settings import BaseSettings; class Settings(BaseSettings): db_url: str = "sqlite:///app.db". Settings() reads DB_URL from env. .env file: model_config = SettingsConfigDict(env_file=".env"). Prefix: env_prefix="APP_" — reads APP_DB_URL. Nested: class DBSettings(BaseSettings): host: str; class Settings(BaseSettings): db: DBSettings = DBSettings(). Dotenv: Settings(_env_file=".env.prod") — override. Case: env_ignore_empty=True. Secret: password: SecretStr — get_secret_value(). SecretBytes. DB URL: from pydantic import PostgresDsn. Bool coercion: debug: bool — env "true"|"1"|"yes" → True. List: allowed_hosts: list[str] — env "a,b,c" → ["a","b","c"]. extra="ignore" vs extra="forbid". Cached: @lru_cache; def get_settings() -> Settings: return Settings(). FastAPI: Depends(get_settings). @model_validator(mode="after") for cross-field validation. Priority: _secrets_dir > env vars > .env file > defaults. Multiple files: env_file=[".env", ".env.local"]. Reload: create new instance; settings are immutable. Claude Code generates pydantic-settings config classes, FastAPI dependencies, and multi-environment settings.
CLAUDE.md for pydantic-settings
## pydantic-settings Stack
- Version: pydantic-settings >= 2.2 | pip install pydantic-settings
- Class: class Settings(BaseSettings): db_url: str = "..." — reads DB_URL env
- Config: model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
- Secrets: password: SecretStr | api_key: SecretStr — .get_secret_value()
- Cache: @lru_cache; get_settings() → don't re-parse env every call
- Nested: class DBSettings(BaseSettings): ... | db: DBSettings = DBSettings()
- Override: Settings(_env_file=".env.prod") or Settings(db_url="override")
pydantic-settings Configuration Pipeline
# app/settings.py — pydantic-settings env config, secrets, and FastAPI integration
from __future__ import annotations
import os
from functools import lru_cache
from typing import Literal
from pydantic import AnyHttpUrl, PostgresDsn, SecretStr, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# ─────────────────────────────────────────────────────────────────────────────
# Sub-settings models
# ─────────────────────────────────────────────────────────────────────────────
class DatabaseSettings(BaseSettings):
"""Database connection settings — read from DB_* env vars."""
host: str = "localhost"
port: int = 5432
name: str = "appdb"
user: str = "postgres"
password: SecretStr = SecretStr("postgres")
pool_min: int = 2
pool_max: int = 10
echo_sql: bool = False
model_config = SettingsConfigDict(env_prefix="DB_")
@property
def dsn(self) -> str:
"""Compose the connection string from components."""
pw = self.password.get_secret_value()
return f"postgresql://{self.user}:{pw}@{self.host}:{self.port}/{self.name}"
@property
def async_dsn(self) -> str:
pw = self.password.get_secret_value()
return f"postgresql+asyncpg://{self.user}:{pw}@{self.host}:{self.port}/{self.name}"
class RedisSettings(BaseSettings):
"""Redis connection settings — read from REDIS_* env vars."""
host: str = "localhost"
port: int = 6379
db: int = 0
password: SecretStr | None = None
ttl: int = 3600 # default cache TTL in seconds
model_config = SettingsConfigDict(env_prefix="REDIS_")
@property
def url(self) -> str:
auth = ""
if self.password:
auth = f":{self.password.get_secret_value()}@"
return f"redis://{auth}{self.host}:{self.port}/{self.db}"
class AuthSettings(BaseSettings):
"""Auth/JWT settings."""
secret_key: SecretStr
algorithm: str = "HS256"
access_token_ttl: int = 3600 # seconds
refresh_token_ttl: int = 86400 * 30
model_config = SettingsConfigDict(env_prefix="AUTH_")
# ─────────────────────────────────────────────────────────────────────────────
# Main settings class
# ─────────────────────────────────────────────────────────────────────────────
class Settings(BaseSettings):
"""
Application settings loaded from environment variables and .env files.
Priority (highest → lowest): env vars > .env.local > .env > defaults.
"""
# Application
app_name: str = "MyApp"
environment: Literal["development", "staging", "production"] = "development"
debug: bool = False
log_level: str = "INFO"
# Server
host: str = "0.0.0.0"
port: int = 8000
allowed_origins: list[str] = ["http://localhost:3000"]
# Feature flags
enable_cache: bool = True
enable_analytics: bool = False
# External API keys
stripe_api_key: SecretStr | None = None
sendgrid_api_key: SecretStr | None = None
openai_api_key: SecretStr | None = None
model_config = SettingsConfigDict(
env_file=[".env", ".env.local"], # .env.local overrides .env
env_file_encoding="utf-8",
env_nested_delimiter="__", # DB__HOST → db.host
env_ignore_empty=True,
extra="ignore",
)
@property
def is_production(self) -> bool:
return self.environment == "production"
@property
def is_development(self) -> bool:
return self.environment == "development"
@field_validator("log_level")
@classmethod
def validate_log_level(cls, v: str) -> str:
valid = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
upper = v.upper()
if upper not in valid:
raise ValueError(f"log_level must be one of {valid}")
return upper
@model_validator(mode="after")
def check_production_settings(self) -> "Settings":
"""Enforce production-only constraints (e.g. debug must be off)."""
if self.is_production and self.debug:
raise ValueError("debug must be False in production")
return self
# ─────────────────────────────────────────────────────────────────────────────
# Environment-specific subclasses
# ─────────────────────────────────────────────────────────────────────────────
class DevelopmentSettings(Settings):
environment: Literal["development"] = "development"
debug: bool = True
log_level: str = "DEBUG"
model_config = SettingsConfigDict(
env_file=[".env", ".env.development"],
env_file_encoding="utf-8",
extra="ignore",
env_ignore_empty=True,
)
class ProductionSettings(Settings):
environment: Literal["production"] = "production"
debug: bool = False
log_level: str = "WARNING"
enable_analytics: bool = True
model_config = SettingsConfigDict(
env_file=[".env"],
env_file_encoding="utf-8",
extra="ignore",
)
def load_settings() -> Settings:
"""
Load the correct Settings subclass based on the ENVIRONMENT env var.
Defaults to DevelopmentSettings when ENVIRONMENT is not set.
"""
env = os.getenv("ENVIRONMENT", "development").lower()
if env == "production":
return ProductionSettings()
return DevelopmentSettings()
# ─────────────────────────────────────────────────────────────────────────────
# Cached singleton for FastAPI / application use
# ─────────────────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""
Return a cached Settings instance.
@lru_cache ensures env/files are parsed only once per process.
Invalidate with get_settings.cache_clear() in tests.
"""
return load_settings()
def get_db_settings() -> DatabaseSettings:
return DatabaseSettings()
def get_redis_settings() -> RedisSettings:
return RedisSettings()
# ─────────────────────────────────────────────────────────────────────────────
# FastAPI dependencies
# ─────────────────────────────────────────────────────────────────────────────
def make_fastapi_settings_dependency():
"""
Return a FastAPI Depends() factory that injects Settings.
Usage:
settings_dep = make_fastapi_settings_dependency()
@app.get("/health")
async def health(settings: Settings = Depends(settings_dep)):
return {"env": settings.environment}
"""
def _dep() -> Settings:
return get_settings()
return _dep
# ─────────────────────────────────────────────────────────────────────────────
# Utilities
# ─────────────────────────────────────────────────────────────────────────────
def settings_to_dict(settings: Settings, exclude_secrets: bool = True) -> dict:
"""
Serialize settings to a dict for logging or debugging.
exclude_secrets=True: replaces SecretStr values with "***".
"""
raw = settings.model_dump()
if exclude_secrets:
def _redact(d):
result = {}
for k, v in d.items():
from pydantic import SecretStr as _SecretStr
if hasattr(settings, k) and isinstance(getattr(type(settings), k, None), type) and False:
result[k] = "***"
elif isinstance(v, dict):
result[k] = _redact(v)
else:
result[k] = v
return result
# Simpler approach: re-serialize with mode="json" which masks secrets
raw = settings.model_dump(mode="json")
return raw
# ─────────────────────────────────────────────────────────────────────────────
# Demo
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("=== Settings from environment ===")
settings = Settings(
app_name="DemoApp",
debug=True,
log_level="DEBUG",
allowed_origins=["http://localhost:3000", "https://app.example.com"],
stripe_api_key=SecretStr("sk_test_demo"),
)
print(f" app_name: {settings.app_name}")
print(f" environment: {settings.environment}")
print(f" debug: {settings.debug}")
print(f" log_level: {settings.log_level}")
print(f" allowed_origins:{settings.allowed_origins}")
stripe = settings.stripe_api_key
secret_display = stripe.get_secret_value()[:7] + "***" if stripe else None
print(f" stripe_api_key: {secret_display!r} (first 7 chars shown)")
print("\n=== DatabaseSettings ===")
db_settings = DatabaseSettings(
host="db.example.com",
name="production",
password=SecretStr("s3cr3t"),
)
print(f" dsn: {db_settings.dsn}")
print("\n=== RedisSettings ===")
redis_settings = RedisSettings()
print(f" url: {redis_settings.url}")
print("\n=== Environment loading ===")
os.environ["ENVIRONMENT"] = "production"
os.environ["DEBUG"] = "false"
prod_settings = load_settings()
print(f" Loaded: {type(prod_settings).__name__}")
print(f" is_production: {prod_settings.is_production}")
del os.environ["ENVIRONMENT"]
print("\n=== model_dump (JSON-safe) ===")
dumped = settings.model_dump(mode="json")
for k, v in list(dumped.items())[:5]:
print(f" {k}: {v!r}")
For the python-dotenv alternative — python-dotenv just loads .env files into os.environ; pydantic-settings then reads from os.environ with type coercion, validation, defaults, and nested model support — they complement each other, and pydantic-settings includes dotenv loading natively so you only need the one library. For the dynaconf alternative — dynaconf supports multiple configuration formats (TOML, YAML, JSON, .env, Redis), environments via ENV_FOR_DYNACONF, and a layering system; pydantic-settings is tighter: settings are a Python class with full type annotations and validators from pydantic, IDE completion for every setting, and first-class FastAPI integration via Depends(); choose dynaconf when you need multi-format configs, pydantic-settings when you want a typed Python-first API. The Claude Skills 360 bundle includes pydantic-settings skill sets covering BaseSettings with env_file and env_prefix, nested settings with env_nested_delimiter, SecretStr for passwords and API keys, @field_validator and @model_validator, Literal type for environment enum, @lru_cache get_settings() singleton, DevelopmentSettings/ProductionSettings subclasses, load_settings() based on ENVIRONMENT, DatabaseSettings with dsn property, RedisSettings with url property, FastAPI Depends() integration, and settings_to_dict() for safe logging. Start with the free tier to try settings management code generation.