Environment Variable Management
Manage configuration following 12-factor app principles using python-dotenv and Pydantic Settings.
Installation
pip install python-dotenv pydantic-settings
python-dotenv — Basic .env Loading
from dotenv import load_dotenv
import os
# Load .env file (project root)
load_dotenv()
# Read environment variables
db_url = os.getenv("DATABASE_URL", "sqlite:///default.db")
debug = os.getenv("DEBUG", "false").lower() == "true"
secret_key = os.environ["SECRET_KEY"] # raises KeyError if missing
print(db_url)
print(debug)
# .env file
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
SECRET_KEY=super-secret-key-here
DEBUG=false
ALLOWED_HOSTS=localhost,127.0.0.1
REDIS_URL=redis://localhost:6379/0
# .env.example — commit this to version control (keys only, no real values)
DATABASE_URL=postgresql://user:pass@host:5432/dbname
SECRET_KEY=change-me
DEBUG=false
ALLOWED_HOSTS=localhost
REDIS_URL=redis://localhost:6379/0
# .gitignore
.env
.env.local
.env.*.local
Pydantic Settings — Type-Safe Configuration
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, PostgresDsn, RedisDsn, field_validator
from typing import Literal
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore", # ignore undefined env vars
)
# App settings
app_name: str = "My App"
debug: bool = False
environment: Literal["development", "staging", "production"] = "development"
# Database
database_url: PostgresDsn
db_pool_size: int = Field(default=10, ge=1, le=100)
db_max_overflow: int = 20
# Security
secret_key: str = Field(min_length=32)
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30
# Redis
redis_url: RedisDsn = "redis://localhost:6379/0"
# External services
smtp_host: str = "smtp.gmail.com"
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
# Allowed domains
allowed_hosts: list[str] = ["localhost", "127.0.0.1"]
@field_validator("allowed_hosts", mode="before")
@classmethod
def parse_allowed_hosts(cls, v):
if isinstance(v, str):
return [h.strip() for h in v.split(",")]
return v
@property
def is_production(self) -> bool:
return self.environment == "production"
@property
def database_url_str(self) -> str:
return str(self.database_url)
# Singleton pattern (load once at startup)
from functools import lru_cache
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
settings = get_settings()
Environment-Specific Configuration
from pydantic_settings import BaseSettings, SettingsConfigDict
import os
class BaseConfig(BaseSettings):
"""Shared configuration"""
app_name: str = "My App"
api_version: str = "v1"
log_level: str = "INFO"
class DevelopmentConfig(BaseConfig):
model_config = SettingsConfigDict(env_file=".env.development")
debug: bool = True
database_url: str = "sqlite:///dev.db"
log_level: str = "DEBUG"
class StagingConfig(BaseConfig):
model_config = SettingsConfigDict(env_file=".env.staging")
debug: bool = False
database_url: str = "postgresql://..."
class ProductionConfig(BaseConfig):
model_config = SettingsConfigDict(env_file=".env.production")
debug: bool = False
# In production, use real environment variables
def get_config() -> BaseConfig:
env = os.getenv("ENVIRONMENT", "development")
configs = {
"development": DevelopmentConfig,
"staging": StagingConfig,
"production": ProductionConfig,
}
config_class = configs.get(env, DevelopmentConfig)
return config_class()
config = get_config()
FastAPI / Django Integration
# FastAPI
from fastapi import FastAPI, Depends
from functools import lru_cache
app = FastAPI()
@lru_cache
def get_settings() -> Settings:
return Settings()
@app.get("/config")
async def show_config(settings: Settings = Depends(get_settings)):
return {
"app_name": settings.app_name,
"environment": settings.environment,
"debug": settings.debug,
}
# Override in tests
from fastapi.testclient import TestClient
def test_config():
def override_settings():
return Settings(debug=True, secret_key="test" * 8, database_url="...")
app.dependency_overrides[get_settings] = override_settings
client = TestClient(app)
response = client.get("/config")
assert response.status_code == 200
12-Factor App Principles Summary
✅ Separate config from code — use .env or OS environment variables
✅ Use environment variables per environment — dev/staging/prod
✅ Exclude secrets from version control — add .env to .gitignore
✅ Provide sensible defaults — easy to start in development
✅ Validate values — use Pydantic for type + range checks
❌ Hardcoded connection strings or API keys
❌ Environment-specific config files committed to code
❌ Mixing dev and production settings in the same file
Summary
| Tool | Role | When to Use |
|---|---|---|
os.getenv | Simple env var reading | Small scripts |
python-dotenv | Load .env files | All projects (baseline) |
Pydantic Settings | Type-safe + validation | FastAPI/Django apps |
Pydantic Settings + .env is the current Python ecosystem standard.