Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 051 / 100

Python settings with pydantic-settings in Python Backend

Typed configuration from environment variables.

Expona Admin6 min read

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:

python
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 = False

Field 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:

python
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:

python
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:

python
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 self

This 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):

python
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=development

Each developer creates their own .env (in .gitignore) by copying the example and filling in real credentials:

python
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=development

Your 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:

python
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 = False

Pydantic-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:

python
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 = False

This 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:

python
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():

python
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 payment

The 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:

python
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
    pass

The 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. Use get_settings() or FastAPI's Depends instead.
  • Missing validation for sensitive values: Don't just validate types; validate that Stripe keys start with sk_ (not pk_), 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 = False makes your code more forgiving of shell environment quirks.

Key takeaways

  • Define a BaseSettings class to centralize configuration, making dependencies explicit and validation automatic.
  • Validation runs at startup, catching missing or invalid configuration before your app serves requests.
  • Use .env files for local development and let environment variables override them in production.
  • Cache settings with lru_cache and 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.example with templates and descriptions so new developers know what to configure.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles