Configuration & Settings
Configuration loads from the environment at startup, validates once with Pydantic 2, and keeps secrets out of source control so the same artifact runs in dev, staging, and production.
Recipe
Quick-reference recipe card - copy-paste ready.
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "billing-api"
database_url: str = Field(alias="DATABASE_URL")
debug: bool = False
settings = Settings()When to reach for this:
- Deploying the same container image across environments
- Preventing "works locally" from missing required env vars
- Centralizing timeouts, feature flags, and service URLs
- Auditing which settings exist without reading scattered
os.getenvcalls
Working Example
# settings.py
from functools import lru_cache
from typing import Literal
from pydantic import Field, PostgresDsn, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
environment: Literal["local", "staging", "production"] = "local"
app_name: str = "orders-api"
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
database_url: PostgresDsn
redis_url: str = "redis://localhost:6379/0"
secret_key: str = Field(min_length=32)
http_timeout_seconds: float = 5.0
@field_validator("secret_key")
@classmethod
def forbid_placeholder_secret(cls, value: str) -> str:
if value in {"changeme", "replace-me"}:
raise ValueError("SECRET_KEY must be set to a real value")
return value
@lru_cache
def get_settings() -> Settings:
return Settings()
# main wiring
import logging
def configure_logging(settings: Settings) -> None:
logging.basicConfig(level=settings.log_level)
def build_app():
settings = get_settings()
configure_logging(settings)
# pass settings.database_url into repository factory
return settings
if __name__ == "__main__":
s = build_app()
print(f"{s.app_name} ready in {s.environment}")# .env.example (committed - no secrets)
DATABASE_URL=postgresql://user:pass@localhost:5432/orders
SECRET_KEY=generate-a-32-char-random-string-for-local-dev
ENVIRONMENT=local
LOG_LEVEL=DEBUGWhat this demonstrates:
- Required secrets fail fast at import/startup with clear validation errors
.envsupports local dev; production relies on real environment variablesget_settings()cached so parsing happens once per processextra="ignore"prevents typos from silently creating attributes
Deep Dive
How It Works
- Pydantic Settings reads env vars (and optional
.env) into typed fields - Field aliases map
DATABASE_URLenv name todatabase_urlattribute - Validators enforce business rules beyond type coercion
- Composition root calls
get_settings()and passes values into adapters
12-Factor Checklist
| Factor | Python practice |
|---|---|
| Config in env | BaseSettings, not hardcoded prod URLs |
| Dev/prod parity | same settings class, different env values |
| Secrets | platform secret store → env at runtime |
| Backing services | URLs as settings, swappable per env |
Python Notes
# Nested settings for subsystems
from pydantic_settings import BaseSettings
class S3Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="S3_")
bucket: str
region: str = "us-east-1"
class Settings(BaseSettings):
s3: S3Settings = S3Settings()Gotchas
- Committing
.envwith secrets - keys leak via git history. Fix: commit.env.exampleonly; block.envin.gitignore. - Reading env vars at import time everywhere - untestable modules and hidden order dependencies. Fix: one settings module; inject into factories.
- No validation on URLs - bad DSN fails deep in SQLAlchemy. Fix:
PostgresDsn,RedisDsn, or custom validators at startup. - Boolean env surprises -
"false"string is truthy in naiveif os.getenv("X"). Fix: Pydanticboolparsing handles0,false,no. - Mutable settings singleton - code mutates
settings.debug = Trueat runtime. Fix: treat settings as immutable after load; usefrozen=Truemodels where practical. - Mixing Django settings with pydantic-settings - two sources of truth. Fix: pick one pattern per app; bridge only at the composition root.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
os.environ directly | 10-line scripts | services with dozens of settings |
Django settings.py | Django 5.2 projects | standalone FastAPI/Flask services |
| TOML/YAML config files | static non-secret defaults | secrets that must not live on disk |
python-decouple | legacy projects already using it | greenfield Pydantic stacks |
FAQs
Should I use .env in production?
Production should inject environment variables via the orchestrator or secret manager. .env files on servers are a common leak vector. Local dev may use .env comfortably.
How do I name environment variables?
Use SCREAMING_SNAKE_CASE. Prefer field aliases when the external name differs from the Python attribute (DATABASE_URL → database_url).
How do I test code that needs settings?
Use monkeypatch.setenv before calling get_settings.cache_clear() and reinstantiating, or pass a manually constructed Settings object into factories.
Can settings load from AWS Secrets Manager?
Yes - fetch secrets in the composition root and pass them as env vars before constructing Settings, or use a custom settings source (Pydantic v2 custom sources).
Where do feature flags go?
Treat flags as settings fields (FEATURE_NEW_CHECKOUT: bool = False). Document defaults in .env.example.
How do I handle multiple environments in CI?
CI sets env vars explicitly in the workflow file. Do not rely on a committed .env for pipeline secrets.
Should nested settings use env_prefix?
Yes for groups like S3_BUCKET, S3_REGION. Keeps flat env vars organized without one giant flat class.
How does this work with Docker?
Pass -e or env_file in compose for non-secrets. Mount secrets from the platform (Kubernetes secrets, ECS secrets) as env vars at container start.
What about default secrets in code?
Never for production paths. Local-only defaults belong in .env.example with obvious placeholder values rejected by validators.
Can I use pydantic-settings with FastAPI?
Yes. Depends(get_settings) provides settings to routes and dependency factories without global imports.
How do I version breaking config changes?
Document renames in CHANGELOG and support deprecated env names for one release with a validator warning.
Are computed settings OK?
Use @computed_field for derived values (e.g. debug mode when environment == "local"). Keep inputs in env, derivations in code.
Related
- Dependency Injection & Wiring - injecting settings at the root
- Project Architecture Decision Checklist - config decisions up front
- Codebase Audit Checklist - secret and config red flags
- Dependency & Environment Conflicts - env mismatch debugging
- Architecture Best Practices - configuration habits
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.