Hardcoding config is easy until you deploy. Here's how to do it properly.
The Problem
# Don't do this
DATABASE_URL = "postgres://user:password@localhost/mydb"
API_KEY = "sk-1234567890"This breaks when you:
- Deploy to production
- Share code with others
- Rotate credentials
- Run tests
Environment Variables 101
Environment variables are key-value pairs set outside your code:
export DATABASE_URL="postgres://..."
export API_KEY="sk-..."Access them in Python:
import os
database_url = os.environ["DATABASE_URL"]
api_key = os.environ["API_KEY"]Use os.environ.get() for Optional Values
# Crashes if missing
required_key = os.environ["API_KEY"]
# Returns None if missing
optional_key = os.environ.get("OPTIONAL_KEY")
# Returns default if missing
debug = os.environ.get("DEBUG", "false")Always use .get() with a default for optional config.
python-dotenv for Local Development
Install:
pip install python-dotenvCreate .env file:
DATABASE_URL=postgres://localhost/mydb
API_KEY=sk-dev-key
DEBUG=true
Load in your code:
from dotenv import load_dotenv
import os
load_dotenv() # Load .env file
database_url = os.environ["DATABASE_URL"]Critical: Add .env to .gitignore. Never commit secrets.
The .env.example Pattern
Commit a template without real values:
# .env.example (committed)
DATABASE_URL=postgres://user:pass@localhost/dbname
API_KEY=your-api-key-here
DEBUG=falseNew developers copy it:
cp .env.example .env
# Edit .env with real valuesType Conversion
Environment variables are always strings:
# This is a string "true", not boolean True
debug = os.environ.get("DEBUG", "false")
# Convert properly
debug = os.environ.get("DEBUG", "false").lower() == "true"
# For integers
port = int(os.environ.get("PORT", "8000"))
# For lists
allowed_hosts = os.environ.get("ALLOWED_HOSTS", "").split(",")A Config Module
Centralize your configuration:
# config.py
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
DATABASE_URL = os.environ["DATABASE_URL"]
API_KEY = os.environ["API_KEY"]
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
PORT = int(os.environ.get("PORT", "8000"))
@classmethod
def validate(cls):
"""Fail fast if required config is missing."""
required = ["DATABASE_URL", "API_KEY"]
missing = [k for k in required if not os.environ.get(k)]
if missing:
raise ValueError(f"Missing required env vars: {missing}")Use it:
from config import Config
Config.validate() # Call at startup
db = connect(Config.DATABASE_URL)Pydantic Settings (Production-Grade)
For larger projects, use pydantic-settings:
pip install pydantic-settingsfrom pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
api_key: str
debug: bool = False
port: int = 8000
class Config:
env_file = ".env"
settings = Settings()
# Now you have validated, typed config
print(settings.database_url)
print(settings.debug) # Already a boolPydantic gives you:
- Automatic type conversion
- Validation
- Clear error messages
- IDE autocomplete
The 12-Factor App Principles
From 12factor.net:
- Store config in environment - not in code
- Strict separation - same code runs in all environments
- No config groups - each var is independent
This means:
- Don't have "dev config" vs "prod config" files
- Each environment sets its own variables
- Code doesn't know what environment it's in
Secrets Management
For production, don't put secrets in plain files:
AWS: Use Parameter Store or Secrets Manager GCP: Use Secret Manager Heroku: Use config vars Docker: Use secrets or environment injection Kubernetes: Use Secrets
Your deployment pipeline injects these at runtime.
Testing with Environment Variables
Override for tests:
# test_config.py
import os
import pytest
@pytest.fixture(autouse=True)
def env_setup(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
monkeypatch.setenv("API_KEY", "test-key")Or use a .env.test file:
from dotenv import load_dotenv
load_dotenv(".env.test")Common Mistakes
Committing .env files
# .gitignore
.env
.env.local
.env.*.localNot validating at startup
# Fail fast, not when you first use the value
Config.validate()Mixing config and code
# Bad - config logic in application code
if os.environ.get("ENV") == "production":
do_thing()
# Better - config exposes a flag
if Config.ENABLE_FEATURE_X:
do_thing()Not having defaults for optional config
# Crashes if missing
timeout = int(os.environ["TIMEOUT"])
# Safe
timeout = int(os.environ.get("TIMEOUT", "30"))My Setup
.env.examplein repo (template).envfor local dev (gitignored)config.pymodule that loads and validates- pydantic-settings for complex projects
- Secrets manager for production
Start simple, add complexity when needed.