Skip to content

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

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.

echo "$MY_VAR"            # read (Linux/macOS)
echo %MY_VAR%             # read (CMD)
echo $env:MY_VAR          # read (PowerShell)

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
# Docker Compose reads .env automatically (see docker-compose.md)
services:
  api:
    env_file: .env

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
# default + validation in one line
port = int(os.environ.get("PORT", 8000))

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

# GitHub Actions (see github-actions.md)
env:
  API_KEY: ${{ secrets.API_KEY }}
run: python deploy.py

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.example documenting 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 && make works.
  • Load .env only 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

Official Documentation

Last Updated

2026-09-06 — Initial version.