Python Environment Variables and 12-Factor App Config
The 12-Factor App methodology mandates that all configuration that varies between environments (development, staging, production) must come from environment variables — never from config files checked into source control. Python's pydantic-settings package enforces this with type-validated, documented settings classes that read from environment variables with a clean fallback hierarchy: env vars → .env files → defaults. This guide covers the full config stack from simple dotenv loading to secrets manager integration.
Table of Contents
pydantic-settings: Typed Config Classes
pip install pydantic-settings python-dotenv
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, AnyHttpUrl, PostgresDsn, RedisDsn, SecretStr
class Settings(BaseSettings):
# App config
APP_NAME: str = "MyApp"
DEBUG: bool = False
ENVIRONMENT: str = "production"
SECRET_KEY: SecretStr = Field(..., description="Django/Flask secret key")
# Server
HOST: str = "0.0.0.0"
PORT: int = 8000
WORKERS: int = 2
# Database
DATABASE_URL: PostgresDsn = Field(..., description="PostgreSQL connection string")
DB_POOL_SIZE: int = 20
DB_MAX_OVERFLOW: int = 10
# Redis
REDIS_URL: RedisDsn = "redis://localhost:6379/0"
REDIS_CACHE_TTL: int = 3600
# External APIs
OPENAI_API_KEY: SecretStr | None = None
STRIPE_SECRET_KEY: SecretStr | None = None
SENTRY_DSN: AnyHttpUrl | None = None
# Email
SMTP_HOST: str = "localhost"
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: SecretStr = SecretStr("")
# Feature flags
ENABLE_ANALYTICS: bool = True
ENABLE_NOTIFICATIONS: bool = True
MAX_UPLOAD_SIZE_MB: int = 10
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True, # DATABASE_URL ≠ database_url
extra="ignore", # ignore unknown env vars
)
# Singleton — instantiated once at import time
settings = Settings()
# Usage
print(settings.APP_NAME)
print(settings.DATABASE_URL)
print(settings.SECRET_KEY.get_secret_value()) # SecretStr hides value in repr
SecretStr: Wrap sensitive values (passwords, API keys, secret keys) in
SecretStr. They display as ********** in logs and repr, preventing accidental exposure in log files or error messages. Call .get_secret_value() only when you actually need the plain text.
.env Files and dotenv Loading
# .env (development — never commit to source control)
APP_NAME=MyApp
DEBUG=true
ENVIRONMENT=development
SECRET_KEY=dev-secret-key-change-in-production
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379/0
OPENAI_API_KEY=sk-...
SMTP_HOST=localhost
SMTP_PORT=1025
# .env.example (template — COMMIT THIS to source control)
APP_NAME=MyApp
DEBUG=false
ENVIRONMENT=production
SECRET_KEY= # generate with: python -c "import secrets; print(secrets.token_hex(32))"
DATABASE_URL=postgresql://USER:PASS@HOST:5432/DB
REDIS_URL=redis://HOST:6379/0
OPENAI_API_KEY= # optional
SENTRY_DSN= # optional
# .gitignore — never commit .env
.env
.env.local
.env.*.local
*.env
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
DATABASE_URL: str
DEBUG: bool = False
model_config = SettingsConfigDict(
# Load multiple .env files in priority order (later files override earlier)
env_file=(".env.local", ".env"),
env_file_encoding="utf-8",
)
# Or load .env manually without pydantic-settings
from dotenv import load_dotenv
load_dotenv(".env") # loads vars into os.environ
import os
db_url = os.environ["DATABASE_URL"]
Validation and Custom Validators
from pydantic import field_validator, model_validator, PostgresDsn
from pydantic_settings import BaseSettings
from typing import Literal
class Settings(BaseSettings):
ENVIRONMENT: Literal["development", "staging", "production"] = "production"
DEBUG: bool = False
DATABASE_URL: str
DATABASE_POOL_SIZE: int = 20
SECRET_KEY: str
ALLOWED_ORIGINS: str = "http://localhost:3000" # comma-separated
@field_validator("SECRET_KEY")
@classmethod
def secret_key_must_be_long(cls, v: str) -> str:
if len(v) < 32:
raise ValueError("SECRET_KEY must be at least 32 characters")
return v
@field_validator("ALLOWED_ORIGINS")
@classmethod
def parse_allowed_origins(cls, v: str) -> list[str]:
return [origin.strip() for origin in v.split(",") if origin.strip()]
@field_validator("DATABASE_POOL_SIZE")
@classmethod
def pool_size_range(cls, v: int) -> int:
if not 1 <= v <= 100:
raise ValueError("DATABASE_POOL_SIZE must be 1-100")
return v
@model_validator(mode="after")
def debug_not_in_production(self) -> "Settings":
if self.ENVIRONMENT == "production" and self.DEBUG:
raise ValueError("DEBUG must be False in production")
return self
@property
def is_production(self) -> bool:
return self.ENVIRONMENT == "production"
@property
def allowed_origins_list(self) -> list[str]:
return self.ALLOWED_ORIGINS if isinstance(self.ALLOWED_ORIGINS, list) else [self.ALLOWED_ORIGINS]
Nested Settings and Prefixes
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseConfig(BaseModel):
url: str = "postgresql://localhost/mydb"
pool_size: int = 20
echo_sql: bool = False
class RedisConfig(BaseModel):
url: str = "redis://localhost:6379/0"
ttl: int = 3600
max_connections: int = 50
class EmailConfig(BaseModel):
host: str = "localhost"
port: int = 587
user: str = ""
password: str = ""
from_address: str = "noreply@example.com"
use_tls: bool = True
class Settings(BaseSettings):
app_name: str = "MyApp"
debug: bool = False
database: DatabaseConfig = DatabaseConfig()
redis: RedisConfig = RedisConfig()
email: EmailConfig = EmailConfig()
model_config = SettingsConfigDict(
env_nested_delimiter="__", # DATABASE__URL, DATABASE__POOL_SIZE
env_file=".env",
)
settings = Settings()
# Set via env vars:
# DATABASE__URL=postgresql://...
# DATABASE__POOL_SIZE=30
# REDIS__URL=redis://...
# EMAIL__HOST=smtp.example.com
Secrets Management Integration
import boto3
import json
import os
from functools import lru_cache
def load_aws_secrets(secret_name: str, region: str = "ap-south-1") -> dict:
"""Fetch a JSON secret from AWS Secrets Manager."""
client = boto3.client("secretsmanager", region_name=region)
response = client.get_secret_value(SecretId=secret_name)
return json.loads(response["SecretString"])
def inject_secrets_into_env(secret_name: str):
"""Load secrets from AWS Secrets Manager and inject into os.environ."""
secrets = load_aws_secrets(secret_name)
for key, value in secrets.items():
os.environ.setdefault(key, str(value))
# At application startup — before Settings() is instantiated
# inject_secrets_into_env("myapp/production")
# settings = Settings()
# pydantic-settings also supports reading secrets from files
# (useful for Kubernetes Secrets mounted as files):
from pydantic_settings import BaseSettings, SettingsConfigDict
class SecretsFromFiles(BaseSettings):
DATABASE_PASSWORD: str
API_SECRET_KEY: str
model_config = SettingsConfigDict(
secrets_dir="/run/secrets", # Docker Secrets or K8s Secret volume mount
env_file=".env",
)
# Reads /run/secrets/DATABASE_PASSWORD file content as the value
Multi-Environment Config Strategy
from functools import lru_cache
from pydantic_settings import BaseSettings
class BaseConfig(BaseSettings):
"""Shared config for all environments."""
APP_NAME: str = "MyApp"
DATABASE_URL: str
REDIS_URL: str = "redis://localhost:6379/0"
SECRET_KEY: str
class DevelopmentConfig(BaseConfig):
DEBUG: bool = True
LOG_LEVEL: str = "DEBUG"
DATABASE_URL: str = "postgresql://user:pass@localhost:5432/myapp_dev"
class Config:
env_file = ".env.development"
class StagingConfig(BaseConfig):
DEBUG: bool = False
LOG_LEVEL: str = "INFO"
class Config:
env_file = ".env.staging"
class ProductionConfig(BaseConfig):
DEBUG: bool = False
LOG_LEVEL: str = "WARNING"
DB_POOL_SIZE: int = 50
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> BaseConfig:
environment = os.environ.get("ENVIRONMENT", "production")
config_map = {
"development": DevelopmentConfig,
"staging": StagingConfig,
"production": ProductionConfig,
}
config_class = config_map.get(environment, ProductionConfig)
return config_class()
# Usage
settings = get_settings()
print(f"Running in {settings.ENVIRONMENT} mode")
FastAPI Dependency Injection
from functools import lru_cache
from fastapi import FastAPI, Depends
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "MyApp"
database_url: str
debug: bool = False
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
return Settings()
app = FastAPI()
@app.get("/config")
async def show_config(settings: Settings = Depends(get_settings)):
return {
"app_name": settings.app_name,
"debug": settings.debug,
"environment": os.environ.get("ENVIRONMENT", "production"),
}
# In tests — override settings
from fastapi.testclient import TestClient
from unittest.mock import patch
def test_config():
with patch.dict(os.environ, {"DATABASE_URL": "sqlite:///test.db", "DEBUG": "true"}):
app.dependency_overrides[get_settings] = lambda: Settings()
client = TestClient(app)
response = client.get("/config")
assert response.json()["debug"] is True
app.dependency_overrides.clear()
Frequently Asked Questions
- Should I commit .env files to source control?
- Never commit real
.envfiles with secrets. Commit a.env.examplewith placeholder values and documentation. Add.envto.gitignore. Store production secrets in a secrets manager (AWS Secrets Manager, HashiCorp Vault, Kubernetes Secrets) and inject them as environment variables at runtime. - How do I handle required vs optional config?
- Fields without defaults (
DATABASE_URL: str) are required — the app fails fast at startup if they're missing. Fields with defaults (DEBUG: bool = False) are optional. This fail-fast behaviour is better than discovering missing config at runtime when the first database query fails. - How do I reload config without restarting?
- Remove the
@lru_cacheand cache invalidation is automatic. For hot-reload of feature flags or non-secret config, use a dedicated config service (LaunchDarkly, AWS AppConfig) that streams updates. For secrets, rotate them in the secrets manager and restart pods — Kubernetes makes this straightforward.