Typed configuration in FastAPI with pydantic-settings

Load and validate environment variables once, fail fast on bad config, and keep secrets out of your code: a small pattern I use in every FastAPI service.

1 min read

Contents
  1. The settings class
  2. Using it in a route
  3. Why SecretStr

Reading os.environ all over a codebase works until a typo in a variable name reaches production. A single typed settings object fixes that.

The settings class

from functools import lru_cache
from pydantic import PostgresDsn, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")

    database_url: PostgresDsn
    stripe_api_key: SecretStr
    debug: bool = False


@lru_cache
def get_settings() -> Settings:
    return Settings()

If APP_DATABASE_URL is missing or isn’t a valid Postgres URL, the service refuses to start. That’s what you want: a crash at boot is much cheaper than a crash at 2 a.m.

Using it in a route

from fastapi import Depends, FastAPI

app = FastAPI()

@app.get("/health")
def health(settings: Settings = Depends(get_settings)):
    return {"debug": settings.debug}

Because it’s a dependency, tests can override it without touching environment variables:

app.dependency_overrides[get_settings] = lambda: Settings(
    database_url="postgresql://test@localhost/test",
    stripe_api_key="sk_test_123",
)

Why SecretStr

SecretStr prints as ********** in logs and tracebacks. You call .get_secret_value() only at the one place that needs the real key.

Small pattern, but it removes a whole class of “works on my machine” bugs.