Environment-Based Config with pydantic-settings
Type-safe configuration from environment variables with validation, defaults, and .env file support
The Problem
Hardcoded config values (host = "localhost") break in production. Scattered os.getenv() calls across the codebase are untyped, unvalidated, and easy to forget. You need config that’s type-safe, validated at startup, and environment-aware.
pydantic-settings — Config as a Model
# app/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
app_name: str = "python-production-blueprint"
app_env: str = "staging" # staging | production
app_version: str = "0.1.0"
app_host: str = "0.0.0.0"
app_port: int = 8000
app_workers: int = 1
# Logging
log_format: str = "json" # json | console
log_level: str = "INFO"
log_file_enabled: bool = False
log_file_path: str = "/var/log/app/app.log"
log_file_max_bytes: int = 52428800 # 50MB
log_file_backup_count: int = 5
# OpenTelemetry
otel_enabled: bool = False
otel_exporter_otlp_endpoint: str = "http://localhost:4317"
otel_service_name: str = "python-production-blueprint"
# Vault
vault_enabled: bool = False
vault_url: str = "http://localhost:8200"
vault_token: str = ""
vault_mount_point: str = "secret"
vault_secret_path: str = "python-production-blueprint"
vault_auth_method: str = "token" # token | approle
vault_role_id: str = ""
vault_secret_id: str = ""
@property
def is_production(self) -> bool:
return self.app_env == "production"
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
"extra": "ignore",
}
settings = Settings()
How It Works
Automatic Environment Mapping
Each field maps to an uppercase environment variable:
| Field | Environment Variable |
|---|---|
app_name | APP_NAME |
log_level | LOG_LEVEL |
vault_enabled | VAULT_ENABLED |
otel_exporter_otlp_endpoint | OTEL_EXPORTER_OTLP_ENDPOINT |
Type Coercion
pydantic-settings automatically converts string env vars to the correct Python type:
export APP_PORT=9000 # str → int
export VAULT_ENABLED=true # str → bool
export LOG_FILE_MAX_BYTES=104857600 # str → int
Validation at Startup
If you set APP_PORT=not_a_number, the app fails to start with a clear error — not midway through handling a request.
Defaults for Development
Every field has a sensible default:
vault_enabled: bool = False— Vault off by defaultotel_enabled: bool = False— Tracing off until explicitly enabledlog_format: str = "json"— JSON logging for machines
.env Files
# .env.staging
APP_NAME=python-production-blueprint
APP_ENV=staging
LOG_LEVEL=DEBUG
LOG_FORMAT=console
LOG_FILE_ENABLED=true
LOG_FILE_PATH=/var/log/app/app.log
VAULT_ENABLED=false
OTEL_ENABLED=false
# .env.production
APP_NAME=python-production-blueprint
APP_ENV=production
LOG_LEVEL=INFO
LOG_FORMAT=json
LOG_FILE_ENABLED=true
VAULT_ENABLED=true
VAULT_URL=https://vault.internal:8200
VAULT_AUTH_METHOD=approle
OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
Priority Order
- Environment variables (highest)
.envfile (frommodel_config)- Field defaults (lowest)
This means Docker env vars override .env file values — exactly what you want in Kubernetes or Swarm.
Computed Properties
@property
def is_production(self) -> bool:
return self.app_env == "production"
Use properties for derived values. The Swagger UI is disabled in production using this:
docs_url="/docs" if not settings.is_production else None,
extra = "ignore"
model_config = {"extra": "ignore"}
Extra environment variables (like PATH, HOME) are silently ignored instead of causing validation errors.
Using Settings
Import the singleton anywhere:
from app.config import settings
# Use it
if settings.otel_enabled:
setup_tracing()
The settings object is created once at import time. All modules share the same instance.
Next Step
In the next lesson, we integrate HashiCorp Vault for centralized secret management — no more secrets in .env files.