4. Using environment variables

Environment variables are name-value pairs that your operating system maintains for each running process. Your program can read them, but they don't live inside your source files, which means you can commit the code without committing the secrets. That separation is what the rest of this chapter depends on.

You've met environment variables already, even if you didn't call them that: PATH is where your shell looks for commands, HOME is your user directory, USER is your username. You can define your own alongside those, and API keys are near-perfect candidates: they're configuration, not logic, and they're sensitive. The OS handles them, Python reads them through the standard library, and Git never sees them.

Setting a variable in your shell

The mechanics are straightforward, but the syntax depends on which shell you're in. Use the block below that matches your terminal. In every case, the variable is live for the current terminal session only; close the window and it's gone, which is exactly what you want while you're experimenting.

macOS and Linux (bash or zsh)

Terminal (macOS/Linux)
# Set the variable (replace with your actual key)
$ export OPENWEATHER_API_KEY="replace_with_your_openweather_key"

# Verify it's set correctly
$ echo $OPENWEATHER_API_KEY
replace_with_your_openweather_key

# Now run your Python script
$ python weather_script.py

export only sets the variable for the current terminal session. Close the window and it's gone. That's fine while you're experimenting, but for credentials you want to survive reboots, you'd add the export line to your shell's rc file (~/.bashrc or ~/.bash_profile for bash, ~/.zshrc for zsh). That said, we're not going to recommend that for project credentials; the next section's .env file pattern is better for per-project keys. Shell rc files are for system-wide configuration; .env files are for project-scoped credentials.

Windows Command Prompt

Windows Command Prompt
# Set the variable (current session only)
C:\> set OPENWEATHER_API_KEY=replace_with_your_openweather_key

# Verify it's set
C:\> echo %OPENWEATHER_API_KEY%
replace_with_your_openweather_key

# Run your script
C:\> python weather_script.py

Windows PowerShell

Windows PowerShell
# Set the variable (current session only)
PS C:\> $env:OPENWEATHER_API_KEY = "replace_with_your_openweather_key"

# Verify it's set
PS C:\> echo $env:OPENWEATHER_API_KEY
replace_with_your_openweather_key

# Run your script
PS C:\> python weather_script.py

A safe first experiment

Before we touch a real API key, let's practise with something harmless so any mistakes cost nothing. Set a test variable in your terminal, using the command that matches your shell:

macOS/Linux
$ export TEST_MESSAGE="Environment variables work!"
Windows Command Prompt
C:\> set TEST_MESSAGE=Environment variables work!
Windows PowerShell
PS C:\> $env:TEST_MESSAGE = "Environment variables work!"

Before you reach for Python, confirm the shell actually has the variable. Echo it back:

macOS/Linux
$ echo $TEST_MESSAGE
Environment variables work!
Windows Command Prompt
C:\> echo %TEST_MESSAGE%
Environment variables work!
Windows PowerShell
PS C:\> echo $env:TEST_MESSAGE
Environment variables work!

Shell confirmed? Good. Now check that Python can read it. Save this at the project root as test_env.py:

test_env.py
import os

message = os.getenv("TEST_MESSAGE")

if message:
    print(f"✓ Success! Python received: {message}")
else:
    print("✗ Failed: TEST_MESSAGE environment variable not found")
    print("Make sure you set it in the same terminal where you run this script")

Run the script in the same terminal window where you set the variable:

Terminal
$ python test_env.py
✓ Success! Python received: Environment variables work!

Green. Three things just happened: you set a variable in the shell, you echoed it to confirm it was there, and Python's os.getenv() read it back. That's the full loop you'll use with real API keys, the only difference is the variable name and the value.

If it didn't work

Four causes account for almost every failure:

  • Wrong terminal window. You have to run Python in the same terminal where you set the variable. A new terminal starts with a fresh environment, so set the variable again there.
  • Forgot to export on macOS or Linux. Use export TEST_MESSAGE="...", not just TEST_MESSAGE="...".
  • Typo in the variable name. TEST_MESSAGE, test_message, and TEST_MESAGE are three different variables. Case and spelling both count.
  • Spaces around the equals sign. KEY=value, not KEY = value.

Work through these before moving on, getting the loop to the point where Python sees the variable is the foundation every later section builds on.

Reading variables from Python

Python's os module offers two ways to read an environment variable. They look almost the same, but only one fails gracefully when the variable is missing:

Safe pattern
import os

# ✅ SAFE: Returns None if variable doesn't exist
api_key = os.getenv("OPENWEATHER_API_KEY")

if api_key is None:
    print("Error: OPENWEATHER_API_KEY environment variable not set")
    print("Set it with: export OPENWEATHER_API_KEY='your-key-here'")
    exit(1)

print(f"API key loaded: {api_key[:8]}...")  # Show first 8 chars only
Anti-pattern
import os

# ❌ UNSAFE: Crashes if variable doesn't exist
api_key = os.environ["OPENWEATHER_API_KEY"]  # KeyError if not set!

# Your program crashes here with a confusing error message
# before you even make an API request

This is the same distinction Chapter 6 drew between dict.get() and bracket access, applied to the OS environment instead of a JSON response. os.getenv("KEY") returns None when the variable is missing, so your code can print a clear "set this variable" message and exit cleanly. os.environ["KEY"] raises KeyError the moment the variable isn't there, and the traceback is exactly as helpful as you'd expect: not very. Always use os.getenv(). It's the same defensive instinct, applied one layer earlier.

Your first authenticated request

Time to put this to work against a real API. Save the following at the project root as env_auth.py. It combines everything you've built in Chapters 3 through 6, a timeout, status-code checks, safe JSON parsing, and defensive extraction, and adds the one new layer Chapter 7 is about: reading the key from the environment, not from the file.

You'll notice the function returns a tuple of (success, data, message) rather than either the data or an exception. That's a deliberate separation of status from result: the caller can decide how to surface the message without worrying about whether the request was authenticated or whether a field was missing. It's the same shape Chapter 6 used for defensive extraction, scaled up to a whole request:

env_auth.py
import requests
import os

def get_weather(city):
    """
    Fetch current weather data with proper credential management
    and comprehensive error handling.
    
    Returns tuple: (success: bool, data: str|None, message: str)
    """
    
    api_key = os.getenv("OPENWEATHER_API_KEY")

    if api_key is None:
        return (
            False,
            None,
            "API key not found. Set OPENWEATHER_API_KEY environment variable."
        )

    # OpenWeatherMap keys are ~32 characters; anything much shorter is a typo.
    if len(api_key) < 10:
        return (
            False,
            None,
            "API key appears invalid (too short). Check OPENWEATHER_API_KEY value."
        )

    url = "https://api.openweathermap.org/data/2.5/weather"
    params = {
        "q": city,
        "appid": api_key,
        "units": "metric",
    }

    try:
        response = requests.get(url, params=params, timeout=10)

        if response.status_code == 401:
            return (
                False,
                None,
                "Authentication failed. Your API key may be invalid or not yet activated. "
                "New keys can take up to a couple of hours to activate."
            )

        elif response.status_code == 403:
            return (
                False,
                None,
                "Access forbidden. Your API key works but lacks permission for this operation."
            )

        elif response.status_code == 429:
            retry_after = response.headers.get("Retry-After", "unknown")
            return (
                False,
                None,
                f"Rate limit exceeded. Wait {retry_after} seconds before retrying."
            )

        elif response.status_code == 404:
            return (
                False,
                None,
                f"City '{city}' not found. Check spelling or try a different name."
            )

        elif not response.ok:
            return (
                False,
                None,
                f"Request failed with status code {response.status_code}"
            )

        content_type = response.headers.get("Content-Type", "")
        if "application/json" not in content_type:
            return (
                False,
                None,
                f"Expected JSON response but received {content_type}"
            )

        try:
            data = response.json()
        except ValueError as e:
            return (
                False,
                None,
                f"Invalid JSON in response: {e}"
            )

        main = data.get("main", {})
        weather_list = data.get("weather", [])

        if not isinstance(main, dict):
            return (False, None, "Response missing 'main' data")

        if not isinstance(weather_list, list) or len(weather_list) == 0:
            return (False, None, "Response missing 'weather' data")

        temp = main.get("temp", "Unknown")
        humidity = main.get("humidity", "Unknown")

        weather_info = weather_list[0]
        if not isinstance(weather_info, dict):
            return (False, None, "Invalid weather data structure")

        description = weather_info.get("description", "Unknown")

        result = f"{city}: {temp}°C, {description}, Humidity: {humidity}%"
        return (True, result, "Success")
    
    except requests.exceptions.Timeout:
        return (
            False,
            None,
            "Request timed out. The server took too long to respond."
        )
    
    except requests.exceptions.ConnectionError:
        return (
            False,
            None,
            "Connection error. Check your internet connection."
        )
    
    except requests.exceptions.RequestException as e:
        return (
            False,
            None,
            f"Request failed: {e}"
        )

# Example usage
if __name__ == "__main__":
    cities = ["Dublin", "London", "Paris", "InvalidCityName123"]
    
    for city in cities:
        success, data, message = get_weather(city)
        
        if success:
            print(f"OK {data}")
        else:
            print(f"ERROR {city}: {message}")

Try it yourself. With OPENWEATHER_API_KEY set in your shell, run:

Terminal
$ python env_auth.py
OK Dublin: 11.2°C, scattered clouds, Humidity: 78%
OK London: 10.4°C, overcast clouds, Humidity: 82%
OK Paris: 13.7°C, few clouds, Humidity: 64%
ERROR InvalidCityName123: City 'InvalidCityName123' not found. Check spelling or try a different name.

Three of the four cities return weather; the invented one returns a clear message instead of a crash. Every layer you can see in the output, authentication check, status code handling, safe JSON parsing, defensive field extraction, does the same job you practised in Chapters 3 through 6. Authentication slotted in as exactly one new thing: a credential check at the top of the function, and 401/403/429 as three extra status codes to recognise. Everything else is the pattern you already know, applied to a new kind of request.

The seven gates below visualise the request's path end-to-end: each one either passes and continues, or fails and returns a tuple with a specific message. There is one path to success and seven distinct ways the request can fail, and failure-mode clarity is exactly what makes this kind of code easy to debug later.

API request validation flow
Seven gates from request to success. Fail any gate, return immediately.
START
Gate 1
API key exists?
✓ YES
✗ NO
Return (False, None, "API key not found")
Gate 2
API key valid length?
✓ YES
✗ NO
Return (False, None, "API key invalid")
Gate 3
Make HTTP Request
(timeout=10)
✓ SUCCESS
✗ TIMEOUT
Return (False, None, "Request timed out")
Gate 4
Status code OK?
(200-299)
✓ 200-299
✗ ERROR
401 → "Authentication failed"
403 → "Access forbidden"
429 → "Rate limit exceeded"
404 → "City not found"
Other → "Request failed"
Gate 5
Content-Type JSON?
✓ YES
✗ NO
Return (False, None, "Expected JSON")
Gate 6
Parse JSON
✓ SUCCESS
✗ FAIL
Return (False, None, "Invalid JSON")
Gate 7
Required data present?
✓ YES
✗ NO
Return (False, None, "Missing data")
Return (True, weather_data, "Success")

You now have a working end-to-end flow: a key that lives in the environment, Python that reads it defensively, and a request pipeline that returns a clear message on every failure mode. The trouble with this setup isn't correctness; it's ergonomics. Every new terminal session needs the variable set again. Every teammate needs to know which variables exist and what they're called. The next section fixes that with a file that lives on disk, gets read automatically, and never leaves your machine: the .env pattern.