Configuration management is where many backend applications fail silently—environment variables get typo'd, types mismatch, and deployments break in production. Python settings with pydantic-settings in Python Backend solve this by bringing type safety and validation to your configuration layer, catching issues at startup before they hit your users. In this article, we'll walk through building a robust settings system that validates, loads from .env files, and caches efficiently.
Define a Settings class with fields
The foundation of pydantic-settings is the BaseSettings class, which reads from environment variables and validates them against type hints. Unlike raw os.environ access scattered throughout your codebase, a Settings class centralizes configuration and makes dependencies explicit.
Let's say we're building a SaaS backend that connects to PostgreSQL, Redis, and Stripe. We'll start by defining what configuration we need:
from pydantic_settings import BaseSettings
from pydantic import Field, HttpUrl
from typing import Optional
class Settings(BaseSettings):
# Database
database_url: str = Field(..., description="PostgreSQL connection string")
database_pool_size: int = Field(default=10, description="Connection pool size")
# Redis
redis_url: str = Field(default="redis://localhost:6379/0")
# API
api_port: int = Field(default=8000)
api_host: str = Field(default="0.0.0.0")
# Stripe
stripe_api_key: str = Field(..., description="Secret Stripe API key")
stripe_webhook_secret: str = Field(...)
# Feature flags
enable_analytics: bool = Field(default=False)
log_level: str = Field(default="INFO")
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = FalseField ellipsis (...) marks required fields. When you instantiate Settings(), pydantic-settings reads from environment variables and validates immediately. Missing required fields or type mismatches raise ValidationError with clear messages about what went wrong.
In practice, we instantiate settings once at application startup:
from fastapi import FastAPI
settings = Settings()
app = FastAPI()
@app.on_event("startup")
async def startup():
print(f"Starting on {settings.api_host}:{settings.api_port}")This approach is cleaner than sprinkling os.getenv("STRIPE_API_KEY", "") throughout your service layer. The Settings class becomes a single source of truth.
Validation fails fast at startup
One of the biggest wins with pydantic-settings is that misconfiguration is caught immediately. You don't want your production deployment to fail five minutes into a request because someone forgot to set DATABASE_URL.
Imagine a deployment engineer accidentally deploys without setting the required Stripe key. Without pydantic-settings, your app starts up fine—then crashes when the first payment webhook arrives. With pydantic-settings, the startup itself fails:
try:
settings = Settings()
except ValidationError as e:
print(e)
# Output:
# 1 validation error for Settings
# stripe_api_key
# Field required (type=value_error.missing)You can add custom validators for business logic too. For example, ensure Redis and database aren't the same host in production:
from pydantic import model_validator
class Settings(BaseSettings):
database_url: str
redis_url: str
environment: str = Field(default="development")
@model_validator(mode="after")
def validate_prod_isolation(self) -> "Settings":
if self.environment == "production":
db_host = self.database_url.split("//")[1].split(":")[0]
redis_host = self.redis_url.split("//")[1].split(":")[0]
if db_host == redis_host:
raise ValueError(
"Production: database and Redis must be on different hosts"
)
return selfThis validator runs after all fields are parsed, allowing you to enforce cross-field constraints. Validation errors bubble up immediately, preventing partial or silent failures.
Support .env files in development
Local development is tedious when you export twenty environment variables. The .env file approach, supported natively by pydantic-settings, makes onboarding new team members painless. They clone the repo, copy .env.example to .env, and go.
Here's a realistic development setup. Create .env.example in your repo root (committed to version control):
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp
REDIS_URL=redis://localhost:6379/0
STRIPE_API_KEY=sk_test_fake_key_for_testing
STRIPE_WEBHOOK_SECRET=whsec_test_fake
API_PORT=8000
LOG_LEVEL=DEBUG
ENVIRONMENT=developmentEach developer creates their own .env (in .gitignore) by copying the example and filling in real credentials:
DATABASE_URL=postgresql://alice:devpass@localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379/0
STRIPE_API_KEY=sk_test_alice_real_key
STRIPE_WEBHOOK_SECRET=whsec_test_alice_real
API_PORT=8000
LOG_LEVEL=DEBUG
ENVIRONMENT=developmentYour Settings class automatically loads from .env in development because of the Config class. In staging or production, environment variables (set by Docker, Kubernetes, or your platform) take precedence over any .env file:
class Settings(BaseSettings):
database_url: str
class Config:
env_file = ".env" # Loaded if it exists and no env var is set
env_file_encoding = "utf-8"
case_sensitive = FalsePydantic-settings respects the precedence: direct environment variables override .env values. This means production deployments (which set env vars via secrets managers) work seamlessly without touching .env files.
For multiple environments, you can also use environment-specific .env files:
from pathlib import Path
class Settings(BaseSettings):
database_url: str
environment: str = Field(default="development")
class Config:
env_file = f".env.{os.getenv('ENVIRONMENT', 'development')}"
case_sensitive = FalseThis loads .env.development locally and .env.production in production (if those files exist), while still allowing environment variables to override everything.
Cache settings with lru_cache
Settings should be instantiated once and reused throughout your application. Parsing and validating settings on every request is wasteful. Use functools.lru_cache to ensure you get the same instance:
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
redis_url: str
stripe_api_key: str
class Config:
env_file = ".env"
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()Now, any module that needs settings calls get_settings():
from app.config import get_settings
async def create_payment(order_id: int, amount: float):
settings = get_settings()
stripe.api_key = settings.stripe_api_key
# ... process paymentThe first call creates the Settings instance and validates it. Subsequent calls return the cached instance in O(1) time. This is especially valuable in FastAPI dependency injection:
from fastapi import Depends, FastAPI
app = FastAPI()
async def get_db(settings: Settings = Depends(get_settings)):
async with create_engine(settings.database_url) as engine:
yield engine
@app.get("/orders")
async def list_orders(db = Depends(get_db)):
# db is already initialized with settings.database_url
passThe caching pattern is lightweight and integrates cleanly with FastAPI's dependency injection system. It also enables testing: you can mock get_settings or clear the cache with get_settings.cache_clear() between tests.
Common mistakes
We've seen several patterns that trip up teams:
- Not committing .env.example: New team members won't know what keys are required. Always commit a template with sensible defaults and clear descriptions.
- Using Settings directly instead of dependency injection: Importing
Settings()in multiple places makes testing harder. Useget_settings()or FastAPI'sDependsinstead. - Missing validation for sensitive values: Don't just validate types; validate that Stripe keys start with
sk_(notpk_), that URLs are valid, and that required fields aren't empty strings. Pydantic validators catch these edge cases. - Mixing settings across environments: If your settings include dev database credentials but your production code uses them, you'll have a bad time. Use separate .env files or environment-specific classes.
- Forgetting case_sensitive = False: By default, pydantic-settings respects environment variable case. Setting
case_sensitive = Falsemakes your code more forgiving of shell environment quirks.
Key takeaways
- Define a
BaseSettingsclass to centralize configuration, making dependencies explicit and validation automatic. - Validation runs at startup, catching missing or invalid configuration before your app serves requests.
- Use
.envfiles for local development and let environment variables override them in production. - Cache settings with
lru_cacheand inject via dependency injection to keep your codebase testable. - Add custom validators for cross-field constraints and business logic (e.g., no shared hosts in production).
- Commit
.env.examplewith templates and descriptions so new developers know what to configure.






Comments
Loading comments…