Skip to main content
All posts

Environment Variable Validation in FastAPI: Catching Misconfiguration Before Your SaaS Goes Down

1 June 2026

Environment Variable Validation in FastAPI: Catching Misconfiguration Before Your SaaS Goes Down

I’ve watched too many SaaS deployments fail silently because someone forgot to set STRIPE_API_KEY in production. The app starts fine, requests come in, and then—three hours later when a customer tries to upgrade—the webhook handler crashes with a cryptic NoneType error.

That’s not a code bug. That’s a configuration bug. And it should never reach production.

Most teams validate environment variables reactively: they hardcode references throughout their codebase and hope the variables exist at runtime. This is debugging via crash logs. I prefer validation at startup—fail fast, fail loud, and fail with clarity.

Pydantic’s Settings class (or BaseSettings in v1) gives you exactly this: centralized, type-safe environment variable validation that runs before your first request. No fancy observability stack needed. Just pure, boring reliability.

The Problem: Silent Configuration Failures

Let’s say your FastAPI app needs these variables to function:

Without explicit validation, here’s what happens:

# ❌ BAD: Implicit, scattered validation
@app.get("/api/upgrade")
async def create_checkout(user_id: str):
    stripe.api_key = os.getenv("STRIPE_SECRET_KEY")  # Silent None if missing
    # Crashes hours later when this line actually runs in production
    session = stripe.checkout.Session.create(...)

The app starts. The route is registered. But the bug isn’t caught until someone actually triggers that code path. In production. During a customer’s payment attempt.

The Solution: Pydantic Settings with Startup Validation

I centralize all environment variables in a single Settings class that runs at import time:

# settings.py
from pydantic_settings import BaseSettings
from pydantic import Field, HttpUrl
from typing import Optional


class Settings(BaseSettings):
    # Required variables (will raise ValidationError if missing)
    database_url: str = Field(
        ...,  # ... means required
        description="PostgreSQL connection string",
        examples=["postgresql://user:pass@localhost/dbname"]
    )
    stripe_secret_key: str = Field(
        ...,
        description="Stripe API secret key",
    )
    anthropic_api_key: str = Field(
        ...,
        description="Claude API key for AI features",
    )
    jwt_secret: str = Field(
        ...,
        description="Secret key for JWT token signing",
        min_length=32,  # Enforce minimum security requirement
    )
    
    # Optional variables with defaults
    redis_url: Optional[str] = Field(
        default=None,
        description="Redis connection URL for caching",
    )
    log_level: str = Field(
        default="INFO",
        description="Logging verbosity level",
        pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$",
    )
    environment: str = Field(
        default="development",
        description="Environment name",
        pattern="^(development|staging|production)$",
    )
    
    # Stripe webhook signing key (required only in production)
    stripe_webhook_secret: Optional[str] = Field(
        default=None,
        description="Stripe webhook signing secret",
    )
    
    class Config:
        env_file = ".env"  # Load from .env for local development
        env_file_encoding = "utf-8"
        case_sensitive = False  # Allow STRIPE_SECRET_KEY or stripe_secret_key

    def validate_stripe_webhook_secret(self):
        """Custom validation: webhook secret required in production."""
        if self.environment == "production" and not self.stripe_webhook_secret:
            raise ValueError(
                "stripe_webhook_secret is required in production. "
                "Set STRIPE_WEBHOOK_SECRET environment variable."
            )

    def __init__(self, **data):
        super().__init__(**data)
        self.validate_stripe_webhook_secret()


# Instantiate once at module load time
settings = Settings()

Now, if any required variable is missing, the app fails immediately with a crystal-clear error:

pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
stripe_secret_key
  Field required [type=missing, input_value={}, input_type=dict, ...] 
  For further information visit https://errors.pydantic.dev/2.5/missing

No guessing. No silent None values. The deployment fails before the container even starts.

Integrating with FastAPI Startup

I use FastAPI’s dependency injection to make settings available throughout the app:

# main.py
from fastapi import FastAPI, Depends
from contextlib import asynccontextmanager
import logging

from settings import settings
from api.routes import stripe_routes, ai_routes

# Configure logging based on settings
logging.basicConfig(level=getattr(logging, settings.log_level))
logger = logging.getLogger(__name__)


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup: Settings already validated (done at import)
    logger.info(f"Starting app in {settings.environment} environment")
    
    # Optional: Do additional health checks
    if settings.redis_url:
        logger.info("Redis configured, enabling caching layer")
    
    yield
    
    # Shutdown logic here
    logger.info("Shutting down gracefully")


app = FastAPI(lifespan=lifespan)


# Inject settings into routes via dependency
async def get_settings():
    return settings


@app.get("/health")
async def health_check(cfg: Settings = Depends(get_settings)):
    return {
        "status": "ok",
        "environment": cfg.environment,
    }

For routes that need specific settings (like Stripe), I create focused dependencies:

# api/dependencies.py
from fastapi import Depends
from settings import settings
from typing import Annotated

async def get_stripe_key(cfg: Settings = Depends(get_settings)):
    return cfg.stripe_secret_key

StripKeyDep = Annotated[str, Depends(get_stripe_key)]


# api/routes/stripe.py
@app.post("/api/webhooks/stripe")
async def handle_stripe_webhook(
    body: bytes,
    signature: str = Header(...),
    stripe_key: StripKeyDep = None,
):
    # stripe_key is guaranteed to exist and be non-None
    # Validation happened at startup
    ...

Type Safety and IDE Hints

Because Settings is a Pydantic model with typed fields, your IDE knows exactly what variables exist:

# ✅ Good: Full autocomplete and type hints
stripe.api_key = settings.stripe_secret_key  # Type: str (guaranteed non-None)
db_url = settings.database_url  # Type: str

# ❌ Bad: Lose type information
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")  # Type: str | None (risky)

Gotcha: Custom Validators and Environment-Specific Defaults

Here’s where I burned myself: Pydantic v2 changed how custom validators work. In v1, you’d use @validator; in v2, it’s @field_validator:

# ❌ Pydantic v1 (deprecated)
from pydantic import validator

class Settings(BaseSettings):
    @validator("jwt_secret")
    def validate_jwt_secret(cls, v):
        if len(v) < 32:
            raise ValueError("JWT secret must be at least 32 characters")
        return v

# ✅ Pydantic v2 (correct)
from pydantic import field_validator

class Settings(BaseSettings):
    @field_validator("jwt_secret")
    @classmethod
    def validate_jwt_secret(cls, v: str) -> str:
        if len(v) < 32:
            raise ValueError("JWT secret must be at least 32 characters")
        return v

Also, I learned the hard way: if you have environment-specific secrets (like different Stripe keys for dev vs. prod), don’t store them in .env. Use secrets management:

# ✅ Use environment variables for secrets
# In deployment (Render, Vercel, etc.), set via platform UI or CI/CD
# Locally: .env (gitignored)

# ❌ Don't do this:
dev_stripe_key = "sk_test_..."
prod_stripe_key = "sk_live_..."
active_key = dev_stripe_key if settings.environment == "dev" else prod_stripe_key

Let your deployment platform manage secrets. That’s what they’re for.

One More Thing: Testing with Override

In tests, override settings to avoid real API calls:

# tests/conftest.py
from fastapi.testclient import TestClient
from settings import settings

@pytest.fixture
def test_settings():
    return Settings(
        database_url="sqlite:///test.db",
        stripe_secret_key="sk_test_fake",
        anthropic_api_key="test_key",
        jwt_secret="test_secret_thats_long_enough_" * 2,
    )

@pytest.fixture
def client(test_settings, monkeypatch):
    monkeypatch.setattr("main.settings", test_settings)
    return TestClient(app)

Why This Matters

Every hour your misconfigured SaaS is down costs money: lost transactions, support tickets, customer churn. Pydantic Settings validation is free—it’s already in your dependencies—and it eliminates

You might also like

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch