Skip to main content

Environment Variables - Configuring Python Applications at Runtime

Reading time: ~17 minutes | Level: Foundation → Engineering

Here is a bug that ships to production regularly:

import os

# Set at module load time (import time)
DEBUG = os.environ.get("DEBUG", "false")

def is_debug_mode():
return DEBUG == "true" # Always returns False in production!

The bug: DEBUG is read once at import time and stored as a module-level string. If the environment variable changes after import (or if the test suite sets it after importing the module), is_debug_mode() returns the stale value forever.

The fix:

import os

def is_debug_mode():
return os.environ.get("DEBUG", "false") == "true" # Read at call time

Environment variables look simple but have subtle semantics that cause production incidents. This page covers them thoroughly.

What You Will Learn

  • What environment variables are: OS-level inheritance mechanism for child processes
  • os.environ internals - why it is dict-like but not a regular dict
  • os.environ['KEY'] vs os.environ.get('KEY', default) vs os.getenv() - which to use and when
  • Import-time vs call-time reading - the initialization order trap
  • How setting env vars in Python affects child processes but not parent processes
  • The 12-factor app methodology and why it makes configuration scalable
  • python-dotenv: loading .env files in development without code changes
  • Type coercion: all env vars are strings, and how to convert them safely
  • Fail-fast startup validation: detecting missing required variables at boot
  • Production-grade configuration with Pydantic BaseSettings
  • Security: what never to log, never to commit, and what to use instead

Prerequisites

  • Basic understanding of os.environ as a dictionary-like object
  • Familiarity with Python type conversion (int(), bool(), float())
  • Knowledge of Python dictionaries
  • Having completed topic 05 (os module) is helpful

What Are Environment Variables?

Environment variables are OS-level key-value string pairs stored in a process's environment block. Every process on Unix and Windows has one.

Key insight: environment variables flow downward (from parent to child) only. A child process modifying its environment never affects the parent or sibling processes.

Part 1 - os.environ: Dict-Like But Not a Dict

import os

# os.environ behaves like a dict...
print(type(os.environ)) # <class 'os._Environ'>
print(isinstance(os.environ, dict)) # False - it is NOT a dict!

# But supports all dict operations:
print(os.environ["HOME"]) # /Users/alice
print(os.environ.get("HOME")) # /Users/alice
print(os.environ.get("MISSING")) # None
print("PATH" in os.environ) # True
print(len(os.environ)) # number of env vars

os.environ is an instance of os._Environ, which is a MutableMapping backed by the actual C-level process environment. It is a live view - reads go directly to the OS, writes update the OS environment block immediately.

import os

# Modifying os.environ modifies the LIVE process environment
os.environ["MY_VAR"] = "hello"

# Immediately visible in the OS environment
print(os.environ["MY_VAR"]) # hello

# And in child processes spawned after this point
import subprocess
result = subprocess.run(
["python3", "-c", "import os; print(os.environ['MY_VAR'])"],
capture_output=True, text=True
)
print(result.stdout.strip()) # hello

# But the PARENT process (e.g., the shell that launched Python) is unchanged
# You cannot set environment variables in the parent from Python

Part 2 - Reading Environment Variables Safely

Three Ways to Read: Know the Difference

import os

# Method 1: Direct dict access - raises KeyError if missing
database_url = os.environ["DATABASE_URL"]
# Use when the variable MUST exist. Missing = programmer error = crash is correct.

# Method 2: .get() with default - returns None if missing
debug_str = os.environ.get("DEBUG") # None if not set
debug_str = os.environ.get("DEBUG", "false") # "false" if not set

# Method 3: os.getenv() - identical to os.environ.get()
port_str = os.getenv("PORT", "8080") # "8080" if not set

os.getenv(key, default) is exactly equivalent to os.environ.get(key, default). Choose whichever reads more clearly. Most production code uses os.environ.get() for consistency with dict idioms, and os.environ["KEY"] for required variables.

The Type Coercion Problem

Every environment variable is a string - the OS has no concept of integers, booleans, or lists. You must convert manually, and the conversion can fail.

import os

# All values are strings, always
print(type(os.environ.get("PORT", "8080"))) # <class 'str'>

# Type coercion - the right way
port = int(os.environ.get("PORT", "8080")) # int
timeout = float(os.environ.get("TIMEOUT", "30.0")) # float
workers = int(os.environ.get("WORKERS", str(os.cpu_count() or 1)))

# Boolean coercion is the trickiest part
debug_str = os.environ.get("DEBUG", "false")

# WRONG - this is ALWAYS True because non-empty strings are truthy
debug_wrong = bool(debug_str) # bool("false") == True - bug!

# CORRECT - compare the string
debug = debug_str.lower() in ("true", "1", "yes", "on")
ResultString values
True"true", "1", "yes", "on", "True"
False"false", "0", "no", "off", "False", ""
Ambiguous"maybe" - treat as False (lenient) or raise ValueError (strict)

Never use bool(os.environ.get("DEBUG", "false")) - bool("false") == True because any non-empty string is truthy.

A Type-Safe Helper

import os

def get_env_int(key, default=None):
"""Read an integer environment variable."""
value = os.environ.get(key)
if value is None:
if default is None:
raise KeyError(f"Required environment variable '{key}' is not set")
return default
try:
return int(value)
except ValueError:
raise ValueError(
f"Environment variable '{key}' must be an integer, got: {value!r}"
)

def get_env_bool(key, default=False):
"""Read a boolean environment variable."""
value = os.environ.get(key)
if value is None:
return default
return value.strip().lower() in ("true", "1", "yes", "on")

def get_env_list(key, separator=",", default=None):
"""Read a comma-separated list environment variable."""
value = os.environ.get(key)
if value is None:
return default if default is not None else []
return [item.strip() for item in value.split(separator) if item.strip()]

# Usage
port = get_env_int("PORT", default=8080) # 8080 if not set
debug = get_env_bool("DEBUG", default=False) # False if not set
allowed = get_env_list("ALLOWED_HOSTS") # ["api.example.com", "example.com"]

Part 3 - Import Time vs Call Time: The Initialization Trap

This is the most common environment variable bug in production Python applications.

import os

# ANTI-PATTERN: Read at module import time
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///default.db")

def get_connection():
return connect(DATABASE_URL) # Uses the value captured at import time!

Why this breaks:

  1. Your test suite imports the module before setting DATABASE_URL in the test environment - all tests use the wrong database
  2. In some frameworks (Celery, Gunicorn), worker processes inherit the parent's imported modules - the env var changes after the parent forks, but the captured value is already baked in
  3. Rotating secrets requires restarting the entire Python process, not just updating the env var
import os

# CORRECT: Read at call time
def get_connection():
database_url = os.environ.get("DATABASE_URL", "sqlite:///default.db")
return connect(database_url) # Reads fresh value on every call

# Or if you genuinely want a cached value (intentional), be explicit:
_cached_database_url = None

def get_database_url():
global _cached_database_url
if _cached_database_url is None:
_cached_database_url = os.environ["DATABASE_URL"]
return _cached_database_url

:::note When Import-Time Reading Is Acceptable Reading at import time is acceptable for values that are genuinely constant for the lifetime of the process, like DEBUG, ENV (production/staging/development), or LOG_LEVEL. The key question is: "If this value changed after startup, would I want the running process to pick it up?" If no, import-time is fine. If yes (like database URLs, secrets), read at call time or use a lazy-loading pattern. :::

Part 4 - The 12-Factor App: Configuration via Environment

The 12-factor app methodology is the canonical framework for building deployable, scalable server-side applications. Factor III is: Store config in the environment.

Belongs in environment variables:

  • Database credentials and connection strings
  • API keys and external service credentials
  • Port numbers, hostnames
  • Feature flags (DEBUG, MAINTENANCE_MODE)
  • Environment name (production, staging, development)

Does NOT belong:

  • Application logic and business rules
  • Large blobs of data (use files or object storage)
  • Secrets that need rotation (use a secret manager)

The contract: code is the same binary everywhere. Environment changes behavior without rebuilding.

Development: DATABASE_URL=postgresql://localhost/dev_db
Staging: DATABASE_URL=postgresql://staging-db:5432/staging_db
Production: DATABASE_URL=postgresql://prod-db:5432/prod_db

The Standard Configuration Variables

import os

# The canonical 12-factor config variables for a web application
config = {
# Application identity
"ENV": os.environ.get("ENV", "development"),
"DEBUG": os.environ.get("DEBUG", "false").lower() in ("true", "1"),
"SECRET_KEY": os.environ["SECRET_KEY"], # Required - no default

# Server
"HOST": os.environ.get("HOST", "0.0.0.0"),
"PORT": int(os.environ.get("PORT", "8000")),

# Database
"DATABASE_URL": os.environ["DATABASE_URL"], # Required

# External services
"REDIS_URL": os.environ.get("REDIS_URL", "redis://localhost:6379/0"),
"SMTP_HOST": os.environ.get("SMTP_HOST", "localhost"),
"SMTP_PORT": int(os.environ.get("SMTP_PORT", "587")),

# Performance
"WORKERS": int(os.environ.get("WORKERS", str(os.cpu_count() or 1))),
"LOG_LEVEL": os.environ.get("LOG_LEVEL", "INFO").upper(),
}

Part 5 - python-dotenv: Development Workflow

In development, you do not want to manually export dozens of environment variables before running your application. python-dotenv solves this by loading a .env file automatically.

Installing and Using python-dotenv

pip install python-dotenv
# .env file (in project root - NEVER commit to version control)
DATABASE_URL=postgresql://localhost:5432/myapp_dev
SECRET_KEY=dev-secret-key-not-for-production
DEBUG=true
PORT=8000
REDIS_URL=redis://localhost:6379/0
# config.py or at the top of your main.py
from dotenv import load_dotenv
import os

# Load .env file - only sets variables that are NOT already in the environment
# (Production environments set real values; .env is only for development)
load_dotenv()

# Now os.environ contains the .env values
database_url = os.environ["DATABASE_URL"]

:::warning Never Commit .env to Version Control The .env file contains secrets. Add it to .gitignore immediately when you create a project.

# .gitignore
.env
.env.local
.env.*.local
*.env

Commit a .env.example instead - with placeholder values, no real secrets:

# .env.example - safe to commit, no real values
DATABASE_URL=postgresql://localhost:5432/myapp_dev
SECRET_KEY=your-secret-key-here
DEBUG=true
PORT=8000

:::

How load_dotenv() Works

  • Production: Platform (Heroku, K8s) sets real values - load_dotenv() finds them and skips. .env never interferes.
  • Development: No vars pre-set - load_dotenv() reads .env and sets them.
  • Override: load_dotenv(override=True) - .env values always win.

Multiple .env Files for Multiple Environments

from dotenv import load_dotenv
import os

env = os.environ.get("APP_ENV", "development")

# Load base config, then environment-specific overrides
load_dotenv(".env") # Base (development defaults)
load_dotenv(f".env.{env}", override=True) # Environment-specific overrides
# .env.production, .env.staging, .env.test

Part 6 - Fail Fast: Validating Required Variables at Startup

The worst time to discover a missing environment variable is when your application crashes during a request, three hours after deployment. Validate at startup.

Simple Fail-Fast Pattern

import os
import sys

def require_env(*keys):
"""Exit immediately if any required environment variable is missing."""
missing = [key for key in keys if not os.environ.get(key)]
if missing:
print(f"ERROR: Missing required environment variables: {missing}", file=sys.stderr)
print("Set these variables and restart the application.", file=sys.stderr)
sys.exit(1)

# Call at startup, before any application logic
require_env("DATABASE_URL", "SECRET_KEY", "SMTP_HOST")

# If we reach here, all required variables are set
print("Configuration validated successfully")

Production-Grade: Validating With Type Checking

import os
import sys

def validate_config():
"""
Validate and parse all environment variables.
Fail fast with a clear error message if anything is wrong.
Returns a config dict with correctly typed values.
"""
errors = []
config = {}

def get_required(key):
value = os.environ.get(key)
if not value:
errors.append(f" {key}: required but not set")
return None
return value

def get_int(key, default=None, min_val=None, max_val=None):
value = os.environ.get(key)
if value is None:
return default
try:
n = int(value)
except ValueError:
errors.append(f" {key}: must be an integer, got {value!r}")
return default
if min_val is not None and n < min_val:
errors.append(f" {key}: must be >= {min_val}, got {n}")
if max_val is not None and n > max_val:
errors.append(f" {key}: must be <= {max_val}, got {n}")
return n

# Validate each variable
config["database_url"] = get_required("DATABASE_URL")
config["secret_key"] = get_required("SECRET_KEY")
config["env"] = os.environ.get("ENV", "development")
config["debug"] = os.environ.get("DEBUG", "false").lower() in ("true", "1")
config["port"] = get_int("PORT", default=8000, min_val=1, max_val=65535)
config["workers"] = get_int("WORKERS", default=os.cpu_count() or 1, min_val=1, max_val=64)

if config["env"] == "production" and config["debug"]:
errors.append(" DEBUG must not be 'true' in production")

if config["env"] == "production":
sk = config.get("secret_key") or ""
if len(sk) < 32:
errors.append(" SECRET_KEY must be at least 32 characters in production")

if errors:
print("Configuration errors found:", file=sys.stderr)
for error in errors:
print(error, file=sys.stderr)
sys.exit(1)

return config


# Called once at application startup
config = validate_config()

Part 7 - Pydantic Settings: Production Configuration

For production applications, Pydantic Settings (pydantic-settings) provides a complete, type-safe configuration solution with automatic environment variable reading, validation, and documentation.

pip install pydantic-settings
from pydantic_settings import BaseSettings
from pydantic import Field, field_validator
import os

class Settings(BaseSettings):
"""
Application configuration loaded from environment variables.
All fields are automatically read from os.environ.
Types are validated and converted automatically.
"""

# Application
app_name: str = "MyApp"
env: str = Field(default="development", pattern="^(development|staging|production)$")
debug: bool = False
secret_key: str = Field(min_length=32)

# Server
host: str = "0.0.0.0"
port: int = Field(default=8000, ge=1, le=65535)
workers: int = Field(default=1, ge=1, le=64)

# Database
database_url: str # Required \text{---} no default

# Redis (optional)
redis_url: str = "redis://localhost:6379/0"

# Email
smtp_host: str = "localhost"
smtp_port: int = Field(default=587, ge=1, le=65535)
smtp_user: str | None = None
smtp_password: str | None = None

# Logging
log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$")

@field_validator("secret_key")
@classmethod
def validate_secret_key(cls, v, info):
if info.data.get("env") == "production" and v == "dev-secret-not-for-production":
raise ValueError("Using development SECRET_KEY in production is not allowed")
return v

model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
"case_sensitive": False,
}


# Singleton - load once at startup
settings = Settings()

# Usage
print(settings.database_url) # Read from DATABASE_URL env var
print(settings.port) # int, not str - already converted
print(settings.debug) # bool, not str - already converted
print(settings.workers) # int, validated >= 1 and <= 64

Why Pydantic Settings Is the Production Standard

FeatureManual os.environPydantic BaseSettings
Type conversionManual (int(os.environ.get(...)))Automatic
ValidationManual if/raise per variableField constraints
IDE autocompleteNoneFull autocomplete on settings object
Error timingAt call site (runtime surprise)At startup (fail fast)
DocumentationImplicit / scatteredSettings class IS the documentation
BoilerplateRepeated for every variableDefined once in class

Part 8 - Security: What Never to Do With Env Vars

Never Log Environment Variables

import os
import logging

log = logging.getLogger(__name__)

# CATASTROPHIC: logs all secrets to log files, monitoring, Splunk, etc.
# log.info("Starting with config: %s", dict(os.environ))

# STILL BAD: targeted but still logs the secret
# log.debug("Connecting to: %s", os.environ.get("DATABASE_URL"))
# postgres://user:password@host:5432/db <- password in logs!

# CORRECT: log only what is safe
log.info("Starting with PORT=%s, ENV=%s, DEBUG=%s",
os.environ.get("PORT", "8000"),
os.environ.get("ENV", "development"),
os.environ.get("DEBUG", "false"))

Never Put Secrets in Code

# NEVER - commits the secret to git history permanently
# SECRET_KEY = "my-super-secret-key-12345"

# CORRECT
SECRET_KEY = os.environ["SECRET_KEY"] # Required, no default
DATABASE_URL = os.environ["DATABASE_URL"] # Required, no default

:::danger Secrets in git Are Permanent Once a secret is committed to git, it exists in the git history forever - even if you delete the file in a later commit. Anyone with access to the repository (including future employees, external auditors, or attackers who gain access) can find it with git log -p. The only remediation is a complete rewrite of git history (git filter-branch or git filter-repo) plus rotating all exposed credentials. Prevention is free: never commit secrets. :::

Use Secret Managers in Production

# Development: .env file (local only, gitignored)
# Production: use a secret manager

# AWS Secrets Manager example
import boto3
import json

def get_secret(secret_name):
"""Fetch a secret from AWS Secrets Manager."""
client = boto3.client("secretsmanager", region_name="us-east-1")
response = client.get_secret_value(SecretId=secret_name)
return json.loads(response["SecretString"])

# secrets = get_secret("myapp/production/database")
# DATABASE_URL = secrets["DATABASE_URL"]

# Alternative: HashiCorp Vault, GCP Secret Manager, Azure Key Vault
# All follow the same pattern: fetch at startup, cache in memory

Part 9 - Real-World: FastAPI Application Configuration

Here is a complete, production-quality configuration module for a FastAPI application - the pattern used in the EngineersOfAI platform itself:

# backend/app/config.py
"""
Application configuration loaded from environment variables.
All secrets come from the environment - never from code.
"""
from __future__ import annotations

import os
import sys
import logging
from functools import lru_cache
from pydantic_settings import BaseSettings
from pydantic import Field, model_validator

log = logging.getLogger(__name__)


class Settings(BaseSettings):
"""
Central configuration for the application.
Variables are read from environment or .env file.
Validation happens at startup - fail fast on misconfiguration.
"""

# Application
app_name: str = "EngineersOfAI API"
version: str = "1.0.0"
env: str = Field(default="development")
debug: bool = False

# Security
secret_key: str = Field(default="dev-secret-change-in-production")
allowed_origins: list[str] = Field(default=["http://localhost:3000"])
access_token_expire_minutes: int = Field(default=30, ge=1, le=1440)

# Database
database_url: str = Field(
default="postgresql+asyncpg://postgres:postgres@localhost:5432/appdb"
)
db_pool_size: int = Field(default=5, ge=1, le=50)
db_max_overflow: int = Field(default=10, ge=0, le=100)
db_pool_timeout: int = Field(default=30, ge=1, le=120)

# Keycloak Auth
keycloak_url: str = "http://localhost:8080"
keycloak_realm: str = "engineersofai"
keycloak_client_id: str = "engineersofai-backend"

# Server
host: str = "0.0.0.0"
port: int = Field(default=8000, ge=1, le=65535)
workers: int = Field(default=1, ge=1, le=32)

# Logging
log_level: str = Field(default="INFO")

@model_validator(mode="after")
def validate_production_settings(self) -> "Settings":
"""Enforce stricter requirements in production."""
if self.env == "production":
if self.secret_key == "dev-secret-change-in-production":
raise ValueError(
"SECRET_KEY must be changed from default in production. "
"Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
)
if self.debug:
raise ValueError("DEBUG must be False in production")
return self

@property
def is_production(self) -> bool:
return self.env == "production"

@property
def is_development(self) -> bool:
return self.env == "development"

def safe_dict(self) -> dict:
"""Return config as dict with secrets redacted - safe to log."""
d = self.model_dump()
for key in ("secret_key", "database_url"):
if key in d and d[key]:
d[key] = "***REDACTED***"
return d

model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
"case_sensitive": False,
}


@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""
Return the singleton Settings instance.
lru_cache ensures it is created exactly once per process.
In tests, call get_settings.cache_clear() to reset.
"""
try:
settings = Settings()
log.info(
"Configuration loaded: env=%s debug=%s port=%s",
settings.env, settings.debug, settings.port
)
return settings
except Exception as e:
print(f"FATAL: Configuration error: {e}", file=sys.stderr)
sys.exit(1)


# Usage in FastAPI:
# from app.config import get_settings
#
# @app.get("/health")
# async def health(settings: Settings = Depends(get_settings)):
# return {"status": "ok", "env": settings.env}

Interview Questions

Q1: What is the difference between os.environ['KEY'], os.environ.get('KEY'), and os.getenv('KEY')?

Answer: All three read from the process environment, but they differ in failure behavior.

os.environ['KEY'] raises KeyError if the variable is not set. Use for required variables where absence is a programming error.

os.environ.get('KEY') returns None if missing. os.environ.get('KEY', 'default') returns 'default' if missing. Use for optional variables.

os.getenv('KEY', 'default') is exactly equivalent to os.environ.get('KEY', 'default') - it is a convenience alias.

The idiomatic choice is os.environ['KEY'] for required variables (crashes immediately with a clear KeyError on misconfiguration) and os.environ.get('KEY', 'default') for optional variables.

Q2: Why do environment variables only flow from parent to child processes, and what does this mean for setting env vars from Python?

Answer: When a process forks to create a child, the child receives a copy of the parent's environment block. Modifying the child's copy never affects the parent's block because they are separate memory regions. There is no IPC mechanism to push environment changes upward.

From Python: os.environ['KEY'] = 'value' updates the current Python process's environment. Any subprocess spawned after that point inherits the updated value. But the shell or supervisor that launched Python is completely unaffected. This is why you cannot use os.environ to set a shell variable from Python - the shell and Python are separate processes with separate environment blocks.

Q3: What is the 12-factor app methodology's stance on configuration, and why?

Answer: Factor III of the 12-factor methodology states: "Store config in the environment." Configuration is anything that varies between deployment environments - database credentials, external service URLs, feature flags, port numbers.

The rationale: the same code artifact (Docker image, Python package) should run in any environment without modification. Environment variables are the deployment contract: the platform (Heroku, Kubernetes, Nomad) sets the environment; the application reads it. This enables clean separation of concerns, makes secrets auditable (environment is inspectable by ops, not buried in config files), and prevents accidental secret commits to version control.

Q4: Why is bool(os.environ.get("DEBUG", "false")) a bug?

Answer: In Python, every non-empty string is truthy. bool("false") evaluates to True because "false" is a non-empty string. So bool(os.environ.get("DEBUG", "false")) always returns True whether DEBUG is "true" or "false".

The correct pattern is to compare the string explicitly:

debug = os.environ.get("DEBUG", "false").lower() in ("true", "1", "yes", "on")

This returns True only for truthy string values and False for everything else, including "false", "0", "no", and "off".

Q5: What is load_dotenv() and what does it do with variables that are already set in the environment?

Answer: load_dotenv() from the python-dotenv package reads a .env file and sets each KEY=VALUE pair in os.environ, but only if the key is not already present in the environment. This is the correct behavior for 12-factor applications: production environments set real values at the platform level; load_dotenv() sees them already in os.environ and skips them. In development, no values are pre-set in the shell, so load_dotenv() reads the .env file and sets them.

This means the same code works in both development (uses .env) and production (uses real env vars) without any if env == "development" branching. To force .env values to override, use load_dotenv(override=True).

Q6: What is the difference between reading environment variables at import time vs at function call time, and when does it matter?

Answer: Import-time reading (DEBUG = os.environ.get("DEBUG") at module level) captures the value once when the module is first imported. Call-time reading (os.environ.get("DEBUG") inside a function) reads the current value each time the function is called.

Import-time reading causes problems when: (1) tests set environment variables after importing the module - the module already captured the old value; (2) worker processes (Celery, Gunicorn pre-fork) inherit parent module state before the environment is fully configured; (3) configuration needs to be refreshed at runtime.

Call-time reading is safer for anything security-sensitive or environment-dependent. For values that are genuinely constant for the process lifetime (like ENV = "production"), import-time reading is acceptable and makes the intent clear - as long as the code is never tested in isolation with different env var values.

Practice Challenges

Beginner - Type-Safe Config Reader

Write a function load_app_config() that reads the following environment variables and returns a typed dict. If DATABASE_URL or SECRET_KEY is missing, print an error and exit. All other variables have defaults.

Variables to read:

  • DATABASE_URL (required, string)
  • SECRET_KEY (required, string, minimum 8 characters)
  • PORT (optional, integer, default 8000)
  • DEBUG (optional, boolean, default False)
  • LOG_LEVEL (optional, one of DEBUG/INFO/WARNING/ERROR, default INFO)
Solution
import os
import sys

def load_app_config():
"""
Load and validate application configuration from environment variables.
Fails fast with clear error messages on misconfiguration.

Returns:
dict with typed values:
database_url: str
secret_key: str
port: int
debug: bool
log_level: str
"""
errors = []

# Required variables
database_url = os.environ.get("DATABASE_URL")
if not database_url:
errors.append("DATABASE_URL is required but not set")

secret_key = os.environ.get("SECRET_KEY")
if not secret_key:
errors.append("SECRET_KEY is required but not set")
elif len(secret_key) < 8:
errors.append(f"SECRET_KEY must be at least 8 characters (got {len(secret_key)})")

# Optional: PORT
port = 8000
port_str = os.environ.get("PORT")
if port_str is not None:
try:
port = int(port_str)
if not (1 <= port <= 65535):
errors.append(f"PORT must be between 1 and 65535 (got {port})")
except ValueError:
errors.append(f"PORT must be an integer (got {port_str!r})")

# Optional: DEBUG
debug = os.environ.get("DEBUG", "false").strip().lower() in ("true", "1", "yes", "on")

# Optional: LOG_LEVEL
valid_log_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
if log_level not in valid_log_levels:
errors.append(f"LOG_LEVEL must be one of {valid_log_levels} (got {log_level!r})")

# Fail fast
if errors:
print("Configuration error - cannot start:", file=sys.stderr)
for err in errors:
print(f" {err}", file=sys.stderr)
sys.exit(1)

config = {
"database_url": database_url,
"secret_key": secret_key,
"port": port,
"debug": debug,
"log_level": log_level,
}

print(f"Configuration loaded: port={port} debug={debug} log_level={log_level}")
return config


# Test it
if __name__ == "__main__":
os.environ["DATABASE_URL"] = "postgresql://localhost/mydb"
os.environ["SECRET_KEY"] = "my-secret-key-32chars-long-enough"
os.environ["PORT"] = "9000"
os.environ["DEBUG"] = "true"
os.environ["LOG_LEVEL"] = "DEBUG"

config = load_app_config()
print(config)
# Configuration loaded: port=9000 debug=True log_level=DEBUG
# {'database_url': 'postgresql://localhost/mydb',
# 'secret_key': 'my-secret-key-32chars-long-enough',
# 'port': 9000, 'debug': True, 'log_level': 'DEBUG'}

# Verify types
assert isinstance(config["port"], int)
assert isinstance(config["debug"], bool)
assert config["debug"] is True

# Verify boolean coercion is correct
os.environ["DEBUG"] = "false"
config2 = load_app_config()
assert config2["debug"] is False # NOT bool("false") which would be True
print(f"DEBUG=false correctly parsed as: {config2['debug']}") # False

Intermediate - .env File Parser

Without using python-dotenv, write your own parse_dotenv(filepath) function that:

  1. Reads a .env file
  2. Skips blank lines and comments (lines starting with #)
  3. Parses KEY=VALUE pairs, stripping surrounding quotes from values
  4. Returns a dict of {key: value} pairs (does not modify os.environ)
  5. Handles inline comments (e.g., KEY=value # this is a comment)
Solution
import re

def parse_dotenv(filepath):
"""
Parse a .env file and return a dict of key-value pairs.
Does NOT modify os.environ.

Handles:
- KEY=value
- KEY="value with spaces"
- KEY='value with spaces'
- KEY=value # inline comment
- # full line comment
- blank lines
- export KEY=value (shell export syntax)

Args:
filepath: path to the .env file

Returns:
dict mapping string keys to string values
"""
result = {}

try:
with open(filepath, encoding="utf-8") as f:
lines = f.readlines()
except FileNotFoundError:
return {} # Missing .env is not an error

for line_num, line in enumerate(lines, start=1):
line = line.rstrip()

if not line:
continue

if line.lstrip().startswith("#"):
continue

# Strip optional "export " prefix (bash compatibility)
if line.startswith("export "):
line = line[7:]

if "=" not in line:
continue

key, _, raw_value = line.partition("=")
key = key.strip()

if not key:
continue

# Validate key - only alphanumeric and underscore
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key):
print(f"Warning: invalid key {key!r} on line {line_num} \text{---} skipping")
continue

raw_value = raw_value.strip()

# Handle quoted values
if len(raw_value) >= 2 and raw_value[0] in ('"', "'"):
quote_char = raw_value[0]
close_idx = raw_value.find(quote_char, 1)
if close_idx != -1:
value = raw_value[1:close_idx]
# Handle escape sequences in double-quoted strings
if quote_char == '"':
value = value.replace("\\n", "\n")
value = value.replace("\\t", "\t")
value = value.replace('\\"', '"')
value = value.replace("\\\\", "\\")
else:
value = raw_value.lstrip(quote_char)
else:
# Unquoted value \text{---} strip inline comment
comment_match = re.search(r'\s+#.*$', raw_value)
if comment_match:
value = raw_value[:comment_match.start()]
else:
value = raw_value

result[key] = value

return result


def load_dotenv_manual(filepath=".env", override=False):
"""Load parsed .env values into os.environ."""
import os
parsed = parse_dotenv(filepath)
for key, value in parsed.items():
if override or key not in os.environ:
os.environ[key] = value
return parsed


# Demo
if __name__ == "__main__":
import tempfile
import os

env_content = """
# Application settings
DATABASE_URL=postgresql://localhost:5432/mydb
SECRET_KEY="my secret key with spaces"
DEBUG=true
PORT=8080
ALLOWED_HOSTS=localhost,127.0.0.1 # comma-separated list
LOG_LEVEL=INFO
EMPTY_VAR=

# Server settings
export WORKERS=4

# Quoted with newline escape
GREETING="Hello\\nWorld"
"""

with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
f.write(env_content)
temp_path = f.name

result = parse_dotenv(temp_path)
for key, value in result.items():
print(f" {key} = {value!r}")

# Expected output:
# DATABASE_URL = 'postgresql://localhost:5432/mydb'
# SECRET_KEY = 'my secret key with spaces'
# DEBUG = 'true'
# PORT = '8080'
# ALLOWED_HOSTS = 'localhost,127.0.0.1'
# LOG_LEVEL = 'INFO'
# EMPTY_VAR = ''
# WORKERS = '4'
# GREETING = 'Hello\nWorld'

os.unlink(temp_path)

Advanced - Environment Config Manager with Secret Masking

Build a ConfigManager class that:

  1. Loads config from environment variables and an optional .env file
  2. Supports defining a schema with required, type, default, and secret=True fields
  3. Validates all fields at instantiation and raises ConfigurationError with all errors at once
  4. Provides a safe_repr() method that returns a string with all secret=True fields masked as ***
  5. Supports accessing values with config["key"] notation
Solution
import os
import re
from typing import Any, Callable


class ConfigurationError(Exception):
"""Raised when configuration validation fails."""
pass


class FieldDef:
"""Defines how to parse and validate one environment variable."""

def __init__(
self,
env_key: str,
type_: Callable = str,
default: Any = None,
required: bool = False,
secret: bool = False,
validator: Callable | None = None,
description: str = "",
):
self.env_key = env_key
self.type_ = type_
self.default = default
self.required = required
self.secret = secret
self.validator = validator
self.description = description


def _coerce_bool(value: str) -> bool:
return value.strip().lower() in ("true", "1", "yes", "on")


def _coerce_list(sep=",") -> Callable:
def coerce(value: str) -> list[str]:
return [v.strip() for v in value.split(sep) if v.strip()]
return coerce


class ConfigManager:
"""
Type-safe, self-validating configuration manager.
Reads from environment variables (and optionally .env file).
Provides secret masking for safe logging.
"""

def __init__(self, schema: dict[str, FieldDef], env_file: str | None = ".env"):
self._schema = schema
self._values: dict[str, Any] = {}
self._secrets: set[str] = set()

if env_file:
self._load_env_file(env_file)

errors = []
for name, field in schema.items():
raw = os.environ.get(field.env_key)

if raw is None or raw == "":
if field.required and field.default is None:
errors.append(
f" {field.env_key}: required but not set"
+ (f" ({field.description})" if field.description else "")
)
continue
value = field.default
else:
try:
if field.type_ is bool:
value = _coerce_bool(raw)
else:
value = field.type_(raw)
except (ValueError, TypeError) as e:
errors.append(f" {field.env_key}: type conversion failed: {e}")
continue

if field.validator:
try:
result = field.validator(value)
if result is not None:
value = result
except ValueError as e:
errors.append(f" {field.env_key}: validation failed: {e}")
continue

self._values[name] = value
if field.secret:
self._secrets.add(name)

if errors:
raise ConfigurationError(
"Configuration validation failed:\n" + "\n".join(errors)
)

def _load_env_file(self, filepath: str):
"""Simple .env loader - does not override existing env vars."""
try:
with open(filepath, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.startswith("export "):
line = line[7:]
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
except FileNotFoundError:
pass

def __getitem__(self, key: str) -> Any:
if key in self._values:
return self._values[key]
raise KeyError(f"No config key: {key!r}")

def get(self, key: str, default: Any = None) -> Any:
try:
return self[key]
except KeyError:
return default

def safe_repr(self) -> str:
"""Return config as string with secrets masked - safe to log."""
lines = ["ConfigManager {"]
for name, value in self._values.items():
display = "***REDACTED***" if name in self._secrets else repr(value)
lines.append(f" {name} = {display}")
lines.append("}")
return "\n".join(lines)

def __repr__(self) -> str:
return self.safe_repr()


# Usage example
if __name__ == "__main__":
def validate_port(v):
if not (1 <= v <= 65535):
raise ValueError(f"Port must be 1-65535, got {v}")

# Set test env vars
os.environ["DATABASE_URL"] = "postgresql://localhost/mydb"
os.environ["SECRET_KEY"] = "super-secret-key-32-chars-long!!"

config = ConfigManager(
schema={
"database_url": FieldDef(
env_key="DATABASE_URL",
required=True,
secret=True,
description="PostgreSQL connection string"
),
"secret_key": FieldDef(
env_key="SECRET_KEY",
required=True,
secret=True,
),
"port": FieldDef(
env_key="PORT",
type_=int,
default=8000,
validator=validate_port,
),
"debug": FieldDef(
env_key="DEBUG",
type_=bool,
default=False,
),
"allowed_hosts": FieldDef(
env_key="ALLOWED_HOSTS",
type_=_coerce_list(","),
default=["localhost"],
),
},
env_file=None,
)

print(config.safe_repr())
# ConfigManager {
# database_url = ***REDACTED***
# secret_key = ***REDACTED***
# port = 8000
# debug = False
# allowed_hosts = ['localhost']
# }

print(config["port"]) # 8000 (int)
print(config["debug"]) # False (bool)
print(config.get("missing", "fallback")) # fallback

# Test that secrets are accessible for actual use (just not logged)
assert config["database_url"] == "postgresql://localhost/mydb"
assert config["secret_key"] == "super-secret-key-32-chars-long!!"
assert isinstance(config["port"], int)
assert isinstance(config["debug"], bool)
print("All assertions passed")

Quick Reference

OperationCodeNotes
Read required varos.environ["KEY"]Raises KeyError if missing
Read optional varos.environ.get("KEY", "default")Returns default if missing
Read optional varos.getenv("KEY", "default")Identical to .get()
Set a varos.environ["KEY"] = "value"String only; affects children
Delete a vardel os.environ["KEY"]Raises KeyError if missing
Delete safelyos.environ.pop("KEY", None)No error if missing
Check existence"KEY" in os.environBoolean
All env varsdict(os.environ)Snapshot as regular dict
Coerce to intint(os.getenv("PORT", "8080"))Raises ValueError on bad input
Coerce to boolos.getenv("DEBUG","false").lower() in ("true","1")Never use bool(str)
Coerce to listos.getenv("HOSTS","").split(",")Filter empty strings too
Load .env filefrom dotenv import load_dotenv; load_dotenv()pip install python-dotenv
Override .envload_dotenv(override=True).env wins over real env vars
Pydantic settingsclass Settings(BaseSettings): field: int = 8000pip install pydantic-settings
Singleton settings@lru_cache(maxsize=1) def get_settings()One instance per process
Fail fastif not os.environ.get("KEY"): sys.exit(1)Check at startup

Key Takeaways

  • Environment variables are OS-level string key-value pairs that flow from parent to child processes - a child cannot affect its parent's environment
  • os.environ is a live view of the process environment, not a regular dict - reads and writes go to the OS immediately
  • All environment variable values are strings - integer, boolean, and list coercion must be done explicitly; bool("false") == True is the classic pitfall
  • Read environment variables at function call time for mutable config; reading at import time creates stale values in tests and forked workers
  • The 12-factor methodology says config belongs in the environment - the same code binary runs everywhere; only the environment changes
  • python-dotenv's load_dotenv() does not override variables already in the environment - production real values take precedence over .env file defaults
  • Fail fast at startup: detect missing required variables immediately, before any request is processed
  • Pydantic BaseSettings is the production standard - it provides automatic type coercion, field validation, env_file support, and self-documenting config in one class
  • Never log environment variables directly - redact secrets before any logging call; never commit .env or secrets to version control
© 2026 EngineersOfAI. All rights reserved.