Skip to main content

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

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:

PlatformTool
AWSSecrets Manager, Parameter Store
GCPSecret Manager
AzureKey Vault
KubernetesSecrets
Cloudflare WorkersSecrets API
HerokuConfig Vars

These inject secrets as environment variables at runtime — your code reads them the same way.

Common Mistakes

MistakeConsequenceFix
Committing .env to gitSecret exposureAdd to .gitignore
Hardcoding secrets in codeCan't rotate, exposed in gitUse os.environ.get()
No fail-fast for missing varsMysterious crashes laterCheck at startup
Using os.environ["KEY"] for optional varsKeyError crashUse .get("KEY", default)
Trusting .env in productionSecurity riskUse 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()

What's Next

Module 6: Errors, Logging, and Observability