Environment Variables¶
The standard way to configure code without editing it: database URLs, API keys, flags, and paths live in the environment, not in source. Same code, different config per environment.
Table of Contents¶
- Installation / Setup
- Core Syntax
- Common Operations
- Practical Examples
- Common Patterns
- Best Practices
- Common Mistakes
- Troubleshooting
- Related Cheat Sheets
- Official Documentation
Installation / Setup¶
Environment variables are inherited: parent process → child. Set them in your shell, your Docker run, your CI job — then read them in code with os.environ.
No installation needed anywhere — this is an OS/process feature.
Core Syntax¶
Setting variables per platform¶
# Linux / macOS / WSL (bash)
export API_KEY=abc123 # session only
echo 'export API_KEY=abc123' >> ~/.bashrc # persistent; then source ~/.bashrc
# Windows CMD
set API_KEY=abc123 # session only
setx API_KEY abc123 # persistent (new shells)
# PowerShell
$env:API_KEY = "abc123"
[Environment]::SetEnvironmentVariable("API_KEY", "abc123", "User")
Reading in Python¶
import os
api_key = os.environ.get("API_KEY") # None if missing
os.environ.get("DB_URL", "sqlite:///local.db") # with default
# Fail fast for required config
db_url = os.environ["DATABASE_URL"] # KeyError if unset
# pydantic-style typed config (pairs with python-typing.md)
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
api_key: str
log_level: str = "INFO"
settings = Settings() # reads env automatically, typed + validated
.env files (local dev)¶
# .env (gitignored!)
DATABASE_URL=postgresql://localhost/mydb
OPENAI_API_KEY=sk-...
MODEL_PATH=artifacts/best.pt
# load .env into os.environ (python-dotenv)
pip install python-dotenv
from dotenv import load_dotenv
load_dotenv() # at the top of scripts/apps
Common Operations¶
Variable expansion & defaults¶
echo "${VAR:-default}" # default if unset
echo "${VAR:=default}" # also set it
echo "${VAR:?required!}" # error if unset (fail fast)
# one-line: run only if a var is set
[ -n "$API_KEY" ] && ./deploy.sh
Pass into containers¶
docker run -e API_KEY=abc123 -e DB_URL myimage # inline
docker run --env-file .env myimage # from file
docker run -e API_KEY myimage # inherit from host
CI secrets¶
Practical Examples¶
1. Twelve-factor config (the pattern)¶
# config.py
import os
class Config:
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///dev.db")
MLFLOW_TRACKING_URI = os.environ.get("MLFLOW_TRACKING_URI", "http://localhost:5000")
WANDB_API_KEY = os.environ.get("WANDB_API_KEY")
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
2. Per-environment settings¶
# .env.local: DATABASE_URL=sqlite:///dev.db DEBUG=1
# .env.prod: DATABASE_URL=postgres://prod/... DEBUG=0
# shell alias / CI job sets ENV=prod and loads the matching file:
export $(grep -v '^#' ".env.${ENV:-local}" | xargs) # simple loader
3. API keys without committing them¶
# 1) store locally: export OPENAI_API_KEY=sk-...
# 2) keep .env out of git
echo ".env" >> .gitignore
# 3) in CI: Settings → Secrets and variables → Actions → New secret
# 4) in code: os.environ["OPENAI_API_KEY"]
Common Patterns¶
# Central, typed, defaulted, validated (recommended in teams)
from dataclasses import dataclass
from os import environ
@dataclass(frozen=True)
class Env:
db_url: str = environ.get("DATABASE_URL", "sqlite:///dev.db")
debug: bool = environ.get("DEBUG", "0") == "1"
timeout: int = int(environ.get("TIMEOUT_SECONDS", "30"))
# Prefix namespacing avoids collisions: APP_DB_URL, APP_LOG_LEVEL
# Document required vars in a checked-in .env.example (no values)
# cp .env.example .env → fill in local values
Best Practices¶
- Everything configurable via env (URLs, keys, ports, flags) — never hardcode.
- Never commit secrets:
.env, key files, tokens →.gitignore; use CI secret stores. - Commit
.env.exampledocumenting keys and defaults — not the values. - Fail fast on missing required config (
os.environ["X"],:?expansion). - Provide sane dev defaults so
git clone && cp .env.example .env && makeworks. - Load
.envonly in dev — production reads real environment/secret managers (AWS Secrets Manager, Vault, Doppler...). - Keep config out of code reviews: changing a URL should not require a deploy.
- Use one source of truth per environment (env file, compose, CI secrets — not three).
Common Mistakes¶
| Mistake | Consequence | Fix |
|---|---|---|
Committing .env |
Credentials leaked to history | gitignore + rotate; use .env.example |
set vs setx confusion (CMD) |
Var gone in new shells | setx for persistence |
| Reading config at import time in tests | Hard to override | Read lazily or allow injection |
| Defaults hiding production mistakes | Prod silently uses dev DB | No default for critical vars |
Quoting export VAR = value (spaces) |
Runs a command | VAR=value, no spaces |
Trusting .env in prod |
Config drift | Real env / secret manager in prod |
| Logging env values | Secrets in logs | Never print API keys/tokens |
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
Code sees None for a var |
Not exported / wrong shell | export it; restart shell; verify echo $VAR |
| Docker container lacks env | -e/--env-file missing |
Add flags or env_file in compose |
CI shows *** |
Secret used correctly but masked | That's expected — check by consuming it |
New shell doesn't have setx var |
setx affects new shells only | Open a fresh terminal |
.env loaded but value empty |
Trailing quote/comment | Check for quotes & spaces; python -c "from dotenv import load_dotenv; load_dotenv(); import os; print(repr(os.environ.get('X')))" |
| Different behavior local vs CI | Case or missing default | Compare env \| sort outputs deliberately |
Related Cheat Sheets¶
- docker.md · docker-compose.md —
-e/env_file - github-actions.md —
${{ secrets.* }}and${{ vars.* }} - devops.md · ci-cd.md — why config must be environment-driven
- Linux —
export,env; Windows CMD / PowerShell —setx,$env: - python-typing.md — typed settings
- Home · Central Navigation Index
Official Documentation¶
- Twelve-Factor: config
- python-dotenv
- pydantic-settings
- GitHub Actions secrets
- Docker environment variables
Last Updated¶
2026-09-06 — Initial version.