Learning Goals
3 minBy the end of this lesson you can:
- Read environment variables with
os.getenv(with a safe default). - Explain why secrets belong in the environment, never in source code.
- Load a
.envfile in development withpython-dotenv. - Find and change the working directory, and know when
pathlibis the better choice.
Warm-Up · The Hardcoded-Key Disaster
5 minHere's a mistake that has cost real people real money:
# DON'T DO THIS — ever API_KEY = "sk-live-4f8a9b2c0d1e..." # baked into the code client = connect(API_KEY)
Commit that, push to GitHub, and bots scanning public repos will find and abuse the key within minutes. People have woken up to thousands of dollars in cloud charges this way.
Configuration that changes between machines — and especially secrets — lives in the environment, outside your code. Your script reads the value at runtime with os.getenv("API_KEY"). The code is safe to share; the secret stays on the machine. This is the single most important security habit in automation.
New Concept · Environment & Working Directory
14 minWhat is an environment variable?
The operating system keeps a set of name=value pairs available to every program it launches — PATH, HOME, USERNAME, and any you add. They're perfect for configuration because they live outside your code and differ per machine.
Reading them safely
import os # os.environ is a dict-like object of every variable print(os.environ["PATH"]) # KeyError if missing — avoid for optional values # os.getenv is the safe way: returns None (or a default) if missing api_key = os.getenv("API_KEY") debug = os.getenv("DEBUG", "false") # default if not set print("debug mode:", debug)
Use os.getenv(name, default) for optional config so a missing variable doesn't crash your script. Reserve os.environ[name] for values that are genuinely required — then a clear KeyError tells you what's missing.
Setting them (for child processes)
os.environ["MY_FLAG"] = "1" # visible to subprocesses you launch # Note: this only affects THIS process and its children, # not your real shell after the script ends.
The development pattern: a .env file
Typing export API_KEY=... before every run is tedious. In development, store config in a .env file and load it with the python-dotenv library (pip install python-dotenv):
# .env (NEVER commit this — add it to .gitignore) API_KEY=sk-live-abc123 DATABASE_URL=postgres://localhost/mydb DEBUG=true
from dotenv import load_dotenv import os load_dotenv() # reads .env into the environment api_key = os.getenv("API_KEY") # now available like any env var
Always add .env to .gitignore so it's never committed. Commit a .env.example instead — same keys, fake values — so teammates know what to set. In production (servers, CI, cloud) you set real environment variables through the platform, no .env file needed.
The working directory
import os print(os.getcwd()) # current working directory (where you ran the script) os.chdir("/some/folder") # change it (rarely needed)
The "working directory" is where relative paths resolve. open("data.csv") looks in the working directory, which may not be where your script file lives. To reliably find files next to your script, anchor to the script's location:
from pathlib import Path HERE = Path(__file__).resolve().parent # folder containing THIS script data = HERE / "data.csv" # always correct, wherever you run from
For path-building, prefer pathlib (Lesson 5); reach for os mainly for the environment and a few system bits.
Worked Example · A Config-Driven Script
12 minGoal: a script whose behaviour is fully controlled by the environment — so the same code runs in dev and production without edits.
import os from pathlib import Path from dotenv import load_dotenv load_dotenv() # in dev, fills from .env; in prod, real env vars already exist def get_config() -> dict: api_key = os.getenv("API_KEY") if not api_key: raise RuntimeError( "API_KEY is not set. Add it to .env (dev) or your " "platform's environment settings (production)." ) return { "api_key": api_key, "output_dir": Path(os.getenv("OUTPUT_DIR", "output")), "debug": os.getenv("DEBUG", "false").lower() == "true", "max_retries": int(os.getenv("MAX_RETRIES", "3")), } cfg = get_config() cfg["output_dir"].mkdir(parents=True, exist_ok=True) if cfg["debug"]: # never print the whole key — show it's loaded without leaking it print(f"API_KEY loaded (ends with …{cfg['api_key'][-4:]})") print(f"Writing to {cfg['output_dir']}, up to {cfg['max_retries']} retries")
API_KEY loaded (ends with …c123) Writing to output, up to 3 retries
Read the code
Every setting comes from the environment with a sensible default, the required API_KEY fails loudly with a helpful message if missing, and notice the debug print only shows the last four characters of the key — never the whole thing, never in logs. Strings from the environment are always text, so MAX_RETRIES is wrapped in int() and DEBUG is compared as a string. This single pattern lets identical code run safely everywhere.
Try It Yourself
13 minPrint your USERNAME (or USER), HOME (or USERPROFILE on Windows), and the number of folders on your PATH. Use os.getenv with sensible fallbacks.
Write a script that greets os.getenv("GREETING_NAME", "friend"). Run it once normally ("Hello, friend!") and once after setting the variable in your shell.
Hint (PowerShell vs bash)
import os print(f"Hello, {os.getenv('GREETING_NAME', 'friend')}!") # Set it first: # PowerShell: $env:GREETING_NAME = "Aisha"; python greet.py # bash/zsh: GREETING_NAME=Aisha python greet.py
Write require(*names) that checks a list of environment variables are all set, and raises a single error listing every missing one at once (not just the first).
Hint
import os def require(*names): missing = [n for n in names if not os.getenv(n)] if missing: raise RuntimeError("Missing env vars: " + ", ".join(missing)) require("API_KEY", "DATABASE_URL", "SECRET")
Mini-Challenge · The .env Loader
8 minWithout using the dotenv library, write your own load_env(path=".env") that reads a .env file, skips blank lines and # comments, splits each KEY=value, and puts them into os.environ (only if not already set). This is roughly what python-dotenv does under the hood.
Show a sample solution
import os from pathlib import Path def load_env(path=".env"): p = Path(path) if not p.exists(): return for raw in p.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key, value = key.strip(), value.strip().strip('"').strip("'") os.environ.setdefault(key, value) # don't override real env load_env() print(os.getenv("API_KEY"))
Non-negotiables: skip comments/blanks, split on the first =, strip quotes, use setdefault so real env wins.
Recap
3 minConfiguration — and above all secrets — belongs in the environment, never in source code. Read it with os.getenv(name, default) for optional values and os.environ[name] for required ones. In development, keep settings in a .env file loaded by python-dotenv, and always add .env to .gitignore; in production, set real environment variables. Remember everything from the environment is a string, so convert with int() and friends — and never print a full secret. For paths, anchor to Path(__file__).parent and prefer pathlib over the working directory.
Vocabulary Card
- environment variable
- An OS-level name=value pair available to programs at runtime.
- os.getenv
- Reads an env var, returning a default (or None) if it's not set.
- .env file
- A local, git-ignored file of dev config loaded into the environment.
- working directory
- The folder relative paths resolve against — where you ran the script.
Homework
4 minSet up a small project the right way: create a .env with three made-up settings, a matching .env.example with placeholder values, and a .gitignore containing .env. Write config.py with a load_config() function that loads .env, validates required keys, converts types, and returns a config dict — masking any secret in its debug output.
Sample · config.py + files
# .env (git-ignored!) API_KEY=secret-123 TIMEOUT=30 DEBUG=true # .env.example (safe to commit) API_KEY=your-key-here TIMEOUT=30 DEBUG=false # .gitignore .env
import os from dotenv import load_dotenv def load_config() -> dict: load_dotenv() key = os.getenv("API_KEY") if not key: raise RuntimeError("API_KEY missing — copy .env.example to .env") cfg = { "api_key": key, "timeout": int(os.getenv("TIMEOUT", "30")), "debug": os.getenv("DEBUG", "false").lower() == "true", } if cfg["debug"]: print(f"loaded config; key …{key[-3:]}") return cfg
Non-negotiables: .env git-ignored, .env.example committed, required-key check, type conversion, masked secret.