Skip to main content
Advertisement

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

ToolRoleWhen to Use
os.getenvSimple env var readingSmall scripts
python-dotenvLoad .env filesAll projects (baseline)
Pydantic SettingsType-safe + validationFastAPI/Django apps

Pydantic Settings + .env is the current Python ecosystem standard.

Advertisement