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.

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 .env files with secrets. Commit a .env.example with placeholder values and documentation. Add .env to .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_cache and 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.