Config Management in Python: Stop Sprinkling os.environ Across Your Codebase
Scattered os.environ calls and silent type bugs make ops scripts fragile. Pydantic Settings gives you typed, validated, fail-fast config — here's the pattern.
- #python
- #bash
- #pydantic
- #configuration
- #twelve-factor
- #automation
The most common way ops scripts fail in production isn’t a logic bug — it’s config. A missing environment variable that defaults to None and crashes ten minutes into a job. A PORT read as the string "8080" and compared against an integer. A typo in DATABSE_URL that nobody catches until the connection fails. I’ve debugged every one of these, and they share a root cause: config read ad hoc with os.environ.get(...) scattered across the codebase, untyped and unvalidated.
The fix is to treat configuration as a first-class, typed, validated object that loads once at startup and fails loudly if anything’s wrong. In Python, pydantic-settings makes this almost effortless.
The anti-pattern you’re probably using
This is the code I’m asking you to stop writing:
import os
DB_HOST = os.environ.get("DB_HOST", "localhost")
DB_PORT = os.environ.get("DB_PORT", 5432) # bug: this is a STRING from env
TIMEOUT = os.environ.get("TIMEOUT") # bug: None if unset, crashes later
DEBUG = os.environ.get("DEBUG", "false") # bug: "false" is truthy!
Three latent bugs in four lines. DB_PORT from the environment is a string, not an int. TIMEOUT is None and detonates somewhere far from here. And if DEBUG: is always true because the non-empty string "false" is truthy in Python. These fail late, far from the cause — the worst kind of failure.
The pattern: a typed Settings class
pydantic-settings reads environment variables, coerces them to the types you declare, validates them, and gives you a single object. Install it with your tool of choice:
uv add pydantic-settings # or: pip install pydantic-settings
Then declare your config as a class:
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
db_host: str = "localhost"
db_port: int = 5432 # auto-coerced from string
timeout: float = 30.0
debug: bool = False # "false"/"0"/"no" -> False, correctly
api_key: str # no default = REQUIRED
settings = Settings()
Now every problem above is solved at once. db_port is guaranteed an int. timeout is a float. debug parses "false", "0", and "no" to False the way you actually meant. And api_key has no default, so if APP_API_KEY is unset, the program refuses to start with a clear error naming the missing field — instead of crashing later with a cryptic stack trace.
That last property, fail-fast at startup, is the single biggest win. A misconfigured script that won’t even boot is infinitely easier to diagnose than one that runs for ten minutes and dies in an unrelated function.
Reading config: one object, imported everywhere
Instead of os.environ calls scattered through the code, you import one validated object:
from config import settings
def connect():
return Database(host=settings.db_host, port=settings.db_port,
timeout=settings.timeout)
Type checkers and your IDE now autocomplete settings.db_port and know it’s an int. Refactoring a config key becomes a rename, not a grep-and-pray across string literals.
Validation beyond types
Pydantic does more than coerce types — it validates values. You can constrain ranges, validate formats, and reject nonsense before it reaches production:
from pydantic import Field, field_validator
class Settings(BaseSettings):
workers: int = Field(default=4, ge=1, le=64) # must be 1..64
log_level: str = "INFO"
@field_validator("log_level")
@classmethod
def valid_level(cls, v: str) -> str:
allowed = {"DEBUG", "INFO", "WARNING", "ERROR"}
if v.upper() not in allowed:
raise ValueError(f"log_level must be one of {allowed}")
return v.upper()
Now APP_WORKERS=0 or APP_LOG_LEVEL=verbose fails at startup with a precise message. That’s config-as-a-contract: the script states exactly what it needs and rejects anything that doesn’t meet it.
Layering: env vars, .env files, and defaults
The precedence pydantic-settings uses is the twelve-factor order you’d want: real environment variables override the .env file, which overrides code defaults. That means the same code runs locally (reading .env) and in production (reading injected env vars) with no branching:
- Local dev — a gitignored
.envfile holds your values. - CI / production — the platform injects real environment variables, which win.
- Defaults — sensible fallbacks live in the class for everything non-secret.
Never commit secrets. The .env file goes in .gitignore, and production secrets come from your secret manager injected as environment variables. The Settings class doesn’t care where the values come from — it just validates them.
The Bash side of the same idea
Bash can’t do typed validation, but the principle — validate config at startup, fail loudly — still applies. A small guard at the top of a script catches the missing-variable class of bug:
set -euo pipefail
: "${API_KEY:?API_KEY must be set}" # exits immediately if unset
: "${DB_HOST:=localhost}" # default if unset
[[ "$WORKERS" =~ ^[0-9]+$ ]] || { echo "WORKERS must be numeric"; exit 1; }
The ${VAR:?message} form is the Bash equivalent of a required field — it aborts with your message if the variable is unset. It’s not pydantic, but it moves the failure to startup where it belongs.
Why this is worth the small upfront cost
Adding a Settings class feels like ceremony for a 50-line script — until the first time it saves you a 2am debugging session over a typo’d variable name. Typed, validated, fail-fast config is one of those habits that pays for itself the very first time something is misconfigured, which in operations is constantly. It turns a whole category of late, mysterious failures into immediate, obvious ones.
For more configuration and packaging patterns, plus the prompts I use to generate Settings classes from a list of env vars, see the Bash & Python automation guides and our prompt library.
Validate configuration against your real deployment environment, and never commit secrets to version control — inject them at runtime.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.