Hardcoding config is the original sin. Database URLs, API keys, log levels — all of these change between dev, staging, and prod. The twelve-factor app rule: config lives in the environment, never in code.
Python has os.getenv, but it returns strings and zero validation. Enter pydantic-settings — a Pydantic model that reads from env vars, validates types, and gives us autocomplete.
Install
pip install pydantic-settings
A real settings module
# config.py
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "Gyaan API"
debug: bool = False
database_url: str
redis_url: str = "redis://localhost:6379/0"
jwt_secret: str = Field(min_length=32)
jwt_expire_minutes: int = 30
cors_origins: list[str] = ["http://localhost:3000"]
@lru_cache
def get_settings() -> Settings:
return Settings()
A matching .env:
DATABASE_URL=postgresql://user:pass@localhost:5432/gyaan
JWT_SECRET=this-is-at-least-thirty-two-chars-long
DEBUG=true
CORS_ORIGINS=["http://localhost:3000","https://app.pman47.cc"]
Field names are case-insensitive matched against env vars. database_url reads DATABASE_URL. jwt_secret reads JWT_SECRET. List/dict fields take JSON strings.
Why this matters
Three wins over os.getenv:
- Types.
debug: bool = Falseactually parses"true","1","yes"intoTrue. Same for ints, floats, lists. - Validation. Missing required field?
Settings()raises on import. Bad value? Same. Fail fast at startup, not at 3am when a route happens to read it. - Autocomplete.
settings.database_urlinstead ofos.getenv("DATABASE_URL").
Using it as a dependency
from fastapi import Depends, FastAPI
from config import Settings, get_settings
app = FastAPI()
@app.get("/info")
def info(settings: Settings = Depends(get_settings)):
return {"app": settings.app_name, "debug": settings.debug}
Because get_settings is wrapped in @lru_cache, the Settings() object is created once and reused for every request. No re-parsing the env on every call. No re-reading the .env file.
request 1 → get_settings() → reads env, builds Settings, caches it request 2 → get_settings() → returns cached instance (no I/O) request 3 → get_settings() → returns cached instance ... request N → get_settings() → returns cached instance
It also makes overriding easy in tests:
from config import get_settings
def fake_settings():
return Settings(database_url="sqlite:///:memory:", jwt_secret="x" * 32)
app.dependency_overrides[get_settings] = fake_settings
Twelve-factor: why env vars (not config files)
The twelve-factor app says config goes in the environment because:
- Same code, different deploys. Dev, staging, prod all run the same Docker image. Only env differs.
- No secrets in git.
.envis.gitignored. Real prod secrets live in your secrets manager (AWS Secrets Manager, Doppler, Vault). - Language and OS agnostic. Every platform supports env vars. Easy to inject from Docker, Kubernetes, systemd, CI.
.env is a dev convenience — pydantic-settings reads it locally. In prod, the orchestrator (Docker, Kubernetes) sets env vars directly and the .env file simply isn’t there.
Multiple environments
A common pattern: pick the env file based on an ENV var.
import os
env_file = f".env.{os.getenv('ENV', 'dev')}"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=env_file, extra="ignore")
...
Then .env.dev, .env.staging, .env.prod. Or simpler — just use one .env for local dev, and let prod inject real env vars over it.
Nested settings
For grouping related config:
class DBSettings(BaseSettings):
url: str
pool_size: int = 5
model_config = SettingsConfigDict(env_prefix="DB_")
class Settings(BaseSettings):
db: DBSettings = DBSettings()
app_name: str = "Gyaan"
Now DB_URL=... populates settings.db.url.
Don’t do these things
- Don’t store secrets in
.envchecked into git. Add it to.gitignore. Commit.env.exampleinstead. - Don’t call
Settings()directly in route code. Use the cachedget_settingsdependency. - Don’t re-read settings on every request. That’s the whole point of
lru_cache. - Don’t mutate settings at runtime. It’s a snapshot of startup config.
Interview cheat sheet
pydantic-settings= typed, validated env-based config.- Use
BaseSettings+SettingsConfigDict(env_file=".env"). @lru_cacheonget_settings()so we build it once.- Inject as a FastAPI dependency for testability (override in tests).
- Twelve-factor: config in env, secrets out of git, same image across environments.