Environment Variables
What You'll Learn
Why environment variables are the standard way to configure Python programs, how to read them safely, and how to manage them across environments.
Why Environment Variables?
Never hardcode config in your code:
# ❌ Hardcoded — commits secrets to git, breaks in other environments
DB_PASSWORD = "mysecret123"
API_KEY = "sk-abc123xyz"
SERVER = "192.168.1.100"
Use environment variables instead:
# ✅ Read from environment — safe, portable, configurable
import os
DB_PASSWORD = os.environ.get("DB_PASSWORD")
API_KEY = os.environ.get("API_KEY")
SERVER = os.environ.get("SERVER", "localhost")
This way:
- Secrets never appear in your codebase
- Each environment (dev, staging, prod) uses its own values
- Docker, CI/CD, and Kubernetes can inject config at runtime
Reading Environment Variables
import os
# Get — returns None if not set
value = os.environ.get("MY_VAR")
# Get with default
port = os.environ.get("PORT", "8080") # still a string!
port = int(os.environ.get("PORT", "8080")) # convert as needed
# Direct access — raises KeyError if not set
value = os.environ["REQUIRED_VAR"]
# Check if set
if "API_KEY" in os.environ:
key = os.environ["API_KEY"]
# All env vars as a dict
all_vars = dict(os.environ)
Type Conversion Patterns
Environment variables are always strings. Convert them correctly:
import os
# Integer
port = int(os.environ.get("PORT", "8080"))
workers = int(os.environ.get("WORKERS", "4"))
# Float
timeout = float(os.environ.get("TIMEOUT", "30.0"))
# Boolean — several common conventions
debug = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes", "on")
# Path
from pathlib import Path
data_dir = Path(os.environ.get("DATA_DIR", "/var/data"))
# List (comma-separated)
allowed = os.environ.get("ALLOWED_HOSTS", "localhost").split(",")
# ["localhost"] or ["myapp.com", "api.myapp.com"]
Fail Fast on Missing Required Variables
Don't let missing config cause mysterious errors later — check at startup:
import os
import sys
REQUIRED = ["DATABASE_URL", "API_KEY", "SECRET_KEY"]
missing = [var for var in REQUIRED if not os.environ.get(var)]
if missing:
print(f"Error: missing required environment variables: {', '.join(missing)}", file=sys.stderr)
sys.exit(1)
# Now safe to use
db_url = os.environ["DATABASE_URL"]
api_key = os.environ["API_KEY"]
Setting Env Vars in the Shell
# Set for current session
export PORT=8080
export DEBUG=true
export DATABASE_URL="postgresql://user:pass@localhost/mydb"
# Set just for one command (doesn't affect shell)
PORT=8080 DEBUG=true python3 app.py
# Unset a variable
unset API_KEY
# List all env vars
env | grep MY_APP
.env Files for Local Development
Create a .env file for local dev (never commit to git):
# .env — local development settings
DATABASE_URL=postgresql://localhost/myapp_dev
API_KEY=test_key_local_only
DEBUG=true
PORT=8080
LOG_LEVEL=DEBUG
SECRET_KEY=dev-secret-not-for-production
Load with python-dotenv:
pip install python-dotenv
from dotenv import load_dotenv
import os
# Load .env into os.environ (skips vars already set in environment)
load_dotenv()
db_url = os.environ.get("DATABASE_URL")
debug = os.environ.get("DEBUG", "false").lower() == "true"
Always add to .gitignore:
.env
.env.local
.env.*.local
Config Class Pattern (Recommended)
Centralize all env var reading in one place:
import os
import sys
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class AppConfig:
# Required — no defaults
database_url: str
secret_key: str
# Optional with defaults
debug: bool = False
port: int = 8080
log_level: str = "INFO"
data_dir: Path = Path("/var/data")
@classmethod
def from_env(cls) -> "AppConfig":
"""Read config from environment variables."""
# Collect missing required vars
missing = []
database_url = os.environ.get("DATABASE_URL")
secret_key = os.environ.get("SECRET_KEY")
if not database_url:
missing.append("DATABASE_URL")
if not secret_key:
missing.append("SECRET_KEY")
if missing:
print(f"Missing env vars: {', '.join(missing)}", file=sys.stderr)
sys.exit(1)
return cls(
database_url=database_url,
secret_key=secret_key,
debug=os.environ.get("DEBUG", "false").lower() == "true",
port=int(os.environ.get("PORT", "8080")),
log_level=os.environ.get("LOG_LEVEL", "INFO").upper(),
data_dir=Path(os.environ.get("DATA_DIR", "/var/data")),
)
# Usage at startup
from dotenv import load_dotenv
load_dotenv()
config = AppConfig.from_env()
print(f"Starting on port {config.port}, debug={config.debug}")
Environment-Specific Files
Use different .env files per environment:
.env ← local defaults (committed, no secrets)
.env.local ← local overrides (gitignored)
.env.staging ← staging config (gitignored)
.env.production ← never create this! use secrets manager
Load with a specific file:
from dotenv import load_dotenv
load_dotenv(".env.staging")
Secrets in Production
For production, never use .env files. Use a secrets manager:
| Platform | Tool |
|---|---|
| AWS | Secrets Manager, Parameter Store |
| GCP | Secret Manager |
| Azure | Key Vault |
| Kubernetes | Secrets |
| Cloudflare Workers | Secrets API |
| Heroku | Config Vars |
These inject secrets as environment variables at runtime — your code reads them the same way.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Committing .env to git | Secret exposure | Add to .gitignore |
| Hardcoding secrets in code | Can't rotate, exposed in git | Use os.environ.get() |
| No fail-fast for missing vars | Mysterious crashes later | Check at startup |
Using os.environ["KEY"] for optional vars | KeyError crash | Use .get("KEY", default) |
Trusting .env in production | Security risk | Use secrets manager |
Quick Reference
import os
# Read
os.environ.get("KEY") # None if missing
os.environ.get("KEY", "default") # with default
os.environ["KEY"] # KeyError if missing
# Convert
int(os.environ.get("PORT", "8080"))
float(os.environ.get("TIMEOUT", "30.0"))
os.environ.get("DEBUG", "false").lower() == "true"
Path(os.environ.get("DATA_DIR", "/tmp"))
os.environ.get("HOSTS", "a,b").split(",")
# Fail fast
if not os.environ.get("API_KEY"):
sys.exit("API_KEY required")
# .env files
from dotenv import load_dotenv
load_dotenv()