Environment Variables
Environment variables are key-value pairs that live outside your code, in the operating system (or the process that launches your program). They let a single codebase behave differently depending on where it runs — locally on your laptop, in a CI pipeline, in staging, or in production — without changing a single line of source code.
Why not just hardcode configuration?
If you hardcode a database URL, an API key, or a debug flag directly into your source files, you run into two problems: the value can't change between environments without editing code, and secrets end up committed to version control where anyone with repo access (or anyone who finds a leaked copy) can read them. Environment variables solve both problems by keeping configuration and secrets out of the codebase entirely.
Configuration that varies per environment: database hosts, feature flags, log levels, debug mode.
Secrets that must never be checked into git: API keys, database passwords, signing keys, tokens.
Values injected by the deployment platform:
PORT,PATH, cloud provider metadata.
Reading environment variables with os.environ
The os module exposes the current process's environment through os.environ, a dictionary-like object mapping variable names to string values.
import os
# Direct access — raises KeyError if the variable is not set
db_host = os.environ['DB_HOST']
# Safer: os.environ.get() returns None (or a default) if missing
db_host = os.environ.get('DB_HOST')
debug = os.environ.get('DEBUG', 'false') # default value as fallback
print(f"Connecting to {db_host}, debug={debug}")os.getenv() is essentially an alias for os.environ.get() and is the more common idiom in real code:
import os
api_key = os.getenv('API_KEY') # None if not set
port = int(os.getenv('PORT', '8000')) # default fallback, cast to int
if api_key is None:
raise RuntimeError('API_KEY environment variable is required')Setting environment variables at the OS level
Environment variables are normally set outside of Python, in the shell that launches your program, or in the configuration of whatever platform runs your app (Docker, systemd, a cloud provider's dashboard, a CI job).
# macOS / Linux (bash/zsh) — set for a single command DEBUG=true python app.py # Export it for the whole shell session export DATABASE_URL="postgres://user:pass@localhost/mydb" python app.py # Windows PowerShell $env:DEBUG = "true" python app.py
Variables set this way exist only in that process (and its children) — they disappear once the shell session or container ends unless you persist them somewhere (a shell profile, a systemd unit file, a platform's environment variable settings, etc.).
The .env file approach with python-dotenv
Typing export commands every time you start work gets old fast, and it's easy to forget one. The common convention is to put local development configuration in a plain text file named .env at the project root, and load it automatically when your program starts. The python-dotenv package does exactly this.
pip install python-dotenv
DATABASE_URL=postgres://user:pass@localhost/mydb API_KEY=sk_test_1234567890 DEBUG=true
from dotenv import load_dotenv
import os
load_dotenv() # reads .env in the current directory and loads it into os.environ
database_url = os.getenv('DATABASE_URL')
api_key = os.getenv('API_KEY')
debug = os.getenv('DEBUG', 'false').lower() == 'true'
print(database_url)
print(debug)postgres://user:pass@localhost/mydb True
load_dotenv() only fills in variables that aren't already set in the real environment, so a variable exported by your shell or CI system will always take precedence over the same key in .env. That makes .env a safe default for local development that gets overridden cleanly in other environments.
Where load_dotenv looks, and how to be explicit
By default, load_dotenv() searches the current directory and walks upward looking for a .env file. In larger projects it's clearer to pass an explicit path:
from pathlib import Path from dotenv import load_dotenv env_path = Path(__file__).resolve().parent / '.env' load_dotenv(dotenv_path=env_path)
.env .env.local .env.*.local
DATABASE_URL=postgres://user:password@localhost/dbname API_KEY=your-api-key-here DEBUG=false
A pattern for required vs optional variables
import os
from dotenv import load_dotenv
load_dotenv()
def require_env(name: str) -> str:
value = os.getenv(name)
if value is None:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
DATABASE_URL = require_env('DATABASE_URL')
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO') # optional, has a sane defaultFailing fast with a clear error message when a required variable is missing is far easier to debug than a mysterious None propagating deep into your application.
Approach | Where it lives | Typical use |
|---|---|---|
| Current shell session / process tree | Quick local overrides, CI job configuration |
| A file at the project root (gitignored) | Local development defaults, per-developer secrets |
Platform environment settings | Docker, systemd, cloud provider's dashboard | Staging and production configuration |
Environment variables are the standard, framework-agnostic way to keep configuration flexible and secrets out of your source code. Master os.getenv() with sensible defaults, use python-dotenv for a smooth local development experience, and always keep .env out of version control.