Settings with pydantic-settings

FastAPI · Practical · 10 min

The same image runs in dev, staging and production with different environment, and bad config fails at startup instead of mid-request.

Hard-coded secrets and URLs are a classic production incident. The standard FastAPI answer is pydantic-settings, a separate package since Pydantic v2 (pip install pydantic-settings; fastapi[standard] doesn't include it). Subclass BaseSettings and declare typed fields with defaults:

  • Each field is read from an environment variable of the same name, case-insensitively: database_url comes from DATABASE_URL.
  • Values are validated and converted like any model: debug: bool accepts 1 or true, and allowed_origins: list[str] parses a JSON array string.
  • model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_") also reads a .env file and namespaces the variables. Real environment variables win over the file.
  • Fields without defaults are required. A missing APP_SECRET_KEY raises a validation error the moment Settings() is created, so the app fails fast instead of at the first login.
  • SecretStr masks the value in logs and reprs; call .get_secret_value() when you really need it.

Expose settings through a dependency wrapped in functools.lru_cache, so the environment and .env file are read once rather than on every request:

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

Endpoints take settings: Annotated[Settings, Depends(get_settings)], and tests swap values with app.dependency_overrides[get_settings] = lambda: Settings(debug=True, secret_key="test"). A module-level settings = Settings() also works and is common; the dependency version is easier to override and doesn't read the environment at import time.

Never commit .env. Commit a .env.example that lists every variable with a dummy value.

Example

import os
from functools import lru_cache
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
    app_name: str = "Inventory"
    debug: bool = False
    database_url: str = "sqlite:///./app.db"
    secret_key: SecretStr              # required: APP_SECRET_KEY

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

app = FastAPI()

@app.get("/info")
def info(settings: Annotated[Settings, Depends(get_settings)]):
    return {"name": settings.app_name, "debug": settings.debug}

os.environ.update(APP_SECRET_KEY="dev-only", APP_DEBUG="1")
print(TestClient(app).get("/info").json())   # {'name': 'Inventory', 'debug': True}
print(repr(get_settings().secret_key))       # SecretStr('**********')

Remember

  • BaseSettings fields come from environment variables (and optionally a .env file)
  • Values are validated; required fields without values fail at startup
  • Serve settings via an lru_cache'd dependency so tests can override them
  • Use SecretStr for secrets and never commit .env

Watch out for

  • Forgetting that pydantic-settings is a separate install; BaseSettings no longer lives in pydantic itself.

Check yourself

  1. Settings has secret_key: str with no default and the variable isn't set. When does it fail?
  2. Why wrap get_settings in @lru_cache?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: fastapi.tiangolo.com/advanced/settings/