CoursePython · Environment, Tooling, and Version Control · part 2 of 79
Part 2 · Environment, Tooling, and Version Control

Lesson 2: Configuration and Secrets

4 min read·9 Sept 2026

Your code needs an API key. It must not contain one. This lesson covers where configuration should live instead, and how to make a missing or malformed value announce itself immediately rather than fifteen minutes into a job.

.env files and environment variables

An environment variable is a named value that the operating system hands to your process when it starts. Putting configuration there rather than in code means the same code artifact runs in development, staging, and production with different behaviour and no edits.

python
import os
api_key = os.environ["RECIPE_API_KEY"]

Use bracket access rather than os.environ.get("RECIPE_API_KEY"). The bracket form raises KeyError immediately when the variable is missing. The .get() form returns None, which travels silently into your HTTP client and surfaces as a confusing 401 several layers away from the actual mistake. This is the fail-fast principle, formalised later in this lesson.

Local development with .env files. Setting variables by hand every session is tedious, so local development uses a .env file.

bash
# .env, never committedRECIPE_API_KEY=sk-real-key-hereDATABASE_URL=postgresql://localhost:5432/recipesLOG_LEVEL=DEBUG

Make sure it is ignored:

bash
# .gitignore.env.venv/__pycache__/

Then commit a companion file that documents the shape without the values:

bash
# .env.example, committedRECIPE_API_KEY=DATABASE_URL=postgresql://localhost:5432/recipesLOG_LEVEL=DEBUG

A new contributor copies .env.example to .env, fills in their own values, and is running. This tiny file removes most "how do I set this up" questions before they are asked.

One important limit. .env files are a local development convenience only. In production, environment variables come from your deployment platform, container orchestrator, or a secrets manager, never from a file sitting on disk.

The first rule of API keys

A secret never appears in a file that git tracks. Not temporarily, not in a comment, not in a notebook cell, not in a test fixture.

The reason is that git remembers. Committing a key and removing it in the next commit does not remove it. It remains in history, retrievable by anyone with a copy of the repository, forever, unless the history is rewritten. Public repositories are scanned continuously by automated tools, and a leaked cloud credential is typically exploited within minutes. The bill arrives before you notice.

If it happens anyway, the order of operations matters. Rotate the key first. Revoke it at the provider and issue a new one. Cleaning git history is secondary and, on its own, insufficient. Assume that anything ever pushed has been seen.

Two automated safeguards help here, and both appear in Lesson 3. A pre-commit hook that detects private keys blocks the commit before it enters history. A hook that blocks large files stops someone committing a 400 MB model checkpoint that will live in the repository forever.

Config that fails fast at startup

A program that starts successfully and then fails forty minutes into a job because a setting was missing has wasted forty minutes and told you nothing useful. A program that refuses to start, naming the missing setting, has cost you five seconds.

Fail fast means validating all configuration once, at startup, before doing any work.

Pydantic Settings makes this a few lines.

bash
uv add pydantic-settings
python
# src/recipe_extractor/config.pyfrom pydantic import Fieldfrom pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):    """Application configuration, validated once at startup."""
    model_config = SettingsConfigDict(        env_file=".env",        env_file_encoding="utf-8",        extra="forbid",    )
    api_key: str = Field(min_length=10)    database_url: str    log_level: str = "INFO"    request_timeout_seconds: float = Field(default=30.0, gt=0)    max_retries: int = Field(default=3, ge=0, le=10)

settings = Settings()

What this gives you:

Loading. Values come from real environment variables first, then from .env. Field names map to variable names case insensitively, so api_key reads API_KEY.

Type coercion. MAX_RETRIES=5 arrives as the string "5" and becomes the integer 5. Every environment variable is a string. Without this you would convert by hand in every place you use one, and eventually forget once.

Validation. min_lengthgt, and the ge/le pair are enforced at startup. A timeout of 0 is rejected there rather than causing every request to fail instantly at runtime.

Typo protection. extra="forbid" rejects unrecognised keys. Writing API_KEYS= instead of API_KEY= is caught immediately instead of appearing later as a missing value.

Editor support. settings.request_timeout_seconds autocompletes and is type checked. os.environ["REQUEST_TIMEOUT_SECONDS"] is neither.

A missing value produces a precise error before any work begins:

text
pydantic_settings.exceptions.SettingsError: 1 validation error for Settingsapi_key  Field required [type=missing]
Two parallel process flows showing that scattered environment reads fail late during API calls after forty minutes of wasted processing, while validated startup configuration fails immediately at start.
Two parallel process flows showing that scattered environment reads fail late during API calls after forty minutes of wasted processing, while validated startup configuration fails immediately at start.

Use it everywhere.

python
# src/recipe_extractor/parser.pyimport httpx
from recipe_extractor.config import settings

def fetch_recipe(url: str) -> dict:    response = httpx.get(        url,        headers={"Authorization": f"Bearer {settings.api_key}"},        timeout=settings.request_timeout_seconds,    )    response.raise_for_status()    return response.json()

No os.environ calls scattered through the codebase. One validated object, imported where it is needed.

Common mistakes.

Giving secrets a default value. api_key: str = "" turns a loud startup failure into a silent runtime one. Secrets get no default.

Reading configuration at import time inside library modules. Only the settings module should read the environment. Everything else imports the settings object.

Logging the settings object. It contains secrets. If you want to log configuration at startup, log the non-secret fields explicitly.

Concept check. Your service reads a TIMEOUT variable. In staging, someone sets TIMEOUT=thirty. What happens with os.environ["TIMEOUT"], and what happens with the Settings class above?

Answer

With os.environ["TIMEOUT"] you get the string "thirty". It passes through your code silently until something attempts arithmetic on it or hands it to an HTTP client, producing a TypeError far from the cause. In a worse version, a library coerces it in some unexpected way and you get behaviour nobody intended.

With the Settings class, startup fails immediately with a message naming the field, the expected type, and the value received. The deployment fails visibly instead of running incorrectly.