In this post
What this post covers
The same code runs on a laptop, in CI, in staging and in production. Only configuration
differs: database URLs, secrets, feature flags, allowed origins. Scattered os.environ.get(...) calls
are how services end up with a typo’d variable name that silently falls back to a default in production.
pydantic-settings turns configuration into a validated, typed object that fails loudly at
startup, not at 3 a.m. on the first request that needs the missing value.
A pre-flight checklist
Pilots do not discover a missing fuel reading after take-off. Every item is checked on the ground, in a fixed order, and the plane does not leave the gate if one fails. A settings class is the checklist: every value is read, type-checked and validated before the service accepts traffic.
1. A settings class
typed settings from environment variables and a .env file
import os
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class RedisSettings(BaseModel):
url: str = "redis://localhost:6379/0"
pool_size: int = Field(10, ge=1, le=200)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env", env_nested_delimiter="__",
extra="ignore")
env: Literal["dev", "staging", "prod"] = "dev"
database_url: str
jwt_secret: SecretStr # never printed or logged by accident
debug: bool = False
allowed_origins: list[str] = [] # lists and dicts come from JSON strings
request_timeout_s: float = Field(10.0, gt=0, le=120)
redis: RedisSettings = RedisSettings()
Path(".env").write_text( # a developer's local .env (never committed)
"APP_DATABASE_URL=sqlite+aiosqlite:///./dev.db\n"
"APP_JWT_SECRET=dev-only-secret\n"
"APP_DEBUG=true\n", encoding="utf-8")
os.environ["APP_ALLOWED_ORIGINS"] = '["https://shop.example.com", "https://admin.example.com"]'
os.environ["APP_REDIS__POOL_SIZE"] = "25" # nested field: redis.pool_size
settings = Settings()
print(settings.env, settings.debug, settings.database_url)
print(settings.allowed_origins)
print(settings.redis)
print("secret shows as:", settings.jwt_secret, "| real value length:", len(settings.jwt_secret.get_secret_value()))
print(repr(settings)[:90], "…")
Output
dev True sqlite+aiosqlite:///./dev.db
['https://shop.example.com', 'https://admin.example.com']
url='redis://localhost:6379/0' pool_size=25
secret shows as: ********** | real value length: 15
Settings(env='dev', database_url='sqlite+aiosqlite:///./dev.db', jwt_secret=SecretStr('*** …
| Priority (highest first) | Source |
|---|---|
| 1 | arguments passed to Settings(...) (tests use this) |
| 2 | environment variables (APP_DEBUG=false) |
| 3 | the .env file |
| 4 | secrets directory files (secrets_dir="/run/secrets": Docker and Kubernetes secrets) |
| 5 | defaults in the class |
2. Fail at startup, not on the first request
a missing secret and a bad value, reported together
import os
from pydantic import Field, SecretStr, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_")
database_url: str
jwt_secret: SecretStr
request_timeout_s: float = Field(10.0, gt=0, le=120)
os.environ["APP_DATABASE_URL"] = "postgresql+asyncpg://app@db/orders"
os.environ["APP_REQUEST_TIMEOUT_S"] = "300" # a typo'd extra zero
try:
Settings() # in real code: at import of the app factory / lifespan
except ValidationError as e:
print(f"refusing to start, {e.error_count()} configuration errors:")
for err in e.errors():
print(f" APP_{err['loc'][0].upper()}: {err['msg']}")
Output
refusing to start, 2 configuration errors:
APP_JWT_SECRET: Field required
APP_REQUEST_TIMEOUT_S: Input should be less than or equal to 120
3. Settings as a dependency
one cached instance, overridable in tests
from functools import lru_cache
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_")
env: str = "dev"
feature_new_checkout: bool = False
@lru_cache # read the environment once per process
def get_settings() -> Settings:
return Settings()
SettingsDep = Annotated[Settings, Depends(get_settings)]
app = FastAPI()
@app.get("/checkout")
def checkout(settings: SettingsDep):
return {"flow": "new" if settings.feature_new_checkout else "classic", "env": settings.env}
client = TestClient(app)
print(client.get("/checkout").json())
app.dependency_overrides[get_settings] = lambda: Settings(env="test", feature_new_checkout=True)
print(client.get("/checkout").json()) # a test flips a feature flag without touching the environment
app.dependency_overrides.clear()
Output
{'flow': 'classic', 'env': 'dev'}
{'flow': 'new', 'env': 'test'}
Because get_settings is cached, changing environment variables inside a test has no effect after
the first call. Either override the dependency (as above) or call get_settings.cache_clear() in a
fixture. Code that reads settings at import time (a module-level settings = Settings()
used everywhere) cannot be overridden at all, which is one more reason for the app factory (part 07).
4. Configuration rules from production
Secrets never in code or Git
.env in .gitignore; production secrets from the platform (Kubernetes secrets, a vault, a cloud secret manager) via env vars or secrets_dir. Use SecretStr so they never appear in logs or tracebacks.
Required means no default
A production database URL with a localhost default will one day connect production to nothing, or to the wrong thing. Make it required.
Few environment switches
Prefer explicit flags (debug, docs_enabled, log_json) over if env == "prod": scattered through the code.
Log the shape, not the values
At startup, log which settings were loaded and from where. Never log secret values, and SecretStr makes that the default.
Recap
- BaseSettings reads env vars,
.envand secret files into a typed, validated object. - Required fields have no default; invalid config stops the service at startup with every error listed.
- SecretStr for secrets; nested models with
env_nested_delimiter; lists as JSON strings. @lru_cacheget_settings as a dependency, overridden in tests.
Get the free RAG production checklist
25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.