Environment Configuration

Environment configuration lets a program's behavior — connection strings, feature flags, secrets — be set from outside the code, so the same code runs correctly in different contexts.

Environment configuration is the practice of pulling settings a program needs (URLs, credentials, feature flags, log levels) from outside the source code itself — environment variables, configuration files, or command-line arguments — rather than hardcoding them. This is what lets the exact same codebase run correctly in development, testing, and production without being edited between them.

Why Configuration Lives Outside the Code

Hardcoded values (a database URL, an API key) force a code change every time the deployment context changes, and worse, they put secrets directly into version control history. Externalizing configuration means the code reads settings at startup from its environment instead, so the same unmodified program behaves differently in different places simply because it's launched differently.

Environment Variables Basics

Environment variables are name/value string pairs set in the shell or process environment, visible to the program via os.environ (a dict-like mapping) or, for a single lookup, os.environ.get / os.getenv.

Every environment variable value is a plain string; a variable meant to hold a number or boolean must be converted explicitly (there is no automatic typing).

export DATABASE_URL="postgres://localhost/mydb"
export LOG_LEVEL="debug"
python app.py
import os

db_url = os.environ["DATABASE_URL"]     # raises KeyError if unset
log_level = os.environ.get("LOG_LEVEL")  # returns None if unset

Reading With Defaults and Conversion

os.environ.get(name, default) supplies a fallback when a variable is unset, and manual conversion handles anything beyond plain strings.

For a required variable with no sensible default, prefer os.environ[name] (which raises KeyError, or better, a clear custom error) over silently defaulting to something that could mask a misconfiguration.

import os

port = int(os.environ.get("PORT", "8080"))
debug = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")

print(port, debug)   # 8080 False, unless PORT/DEBUG are set in the environment
import os

def require_env(name: str) -> str:
    value = os.environ.get(name)
    if value is None:
        raise RuntimeError(f"Required environment variable {name!r} is not set")
    return value

api_key = require_env("API_KEY")

Configuration Files

For settings too numerous or structured for environment variables, a configuration file works better. The standard library's configparser module reads INI-style files; TOML (Python 3.11+ can read, not write, TOML via the standard-library tomllib) and YAML (third-party PyYAML) are common structured alternatives, with YAML requiring an extra install since it isn't in the standard library.

# config.ini
[database]
host = localhost
port = 5432

[logging]
level = debug
import configparser

config = configparser.ConfigParser()
config.read("config.ini")

host = config["database"]["host"]
port = config.getint("database", "port")
print(host, port)   # localhost 5432
import tomllib   # standard library, Python 3.11+, read-only

with open("config.toml", "rb") as f:
    data = tomllib.load(f)

print(data["database"]["host"])

Precedence

Real applications typically layer several configuration sources with a defined priority order, so a more specific source can override a more general one. A common precedence, lowest to highest: built-in defaults in code, a configuration file, environment variables, then command-line arguments (the most specific, most immediate override).

Each layer is checked in order from most to least specific; the first source that actually provides a value wins.

import argparse
import os

DEFAULT_PORT = 8080

def resolve_port(cli_port: int | None) -> int:
    if cli_port is not None:
        return cli_port                          # highest priority
    env_port = os.environ.get("PORT")
    if env_port is not None:
        return int(env_port)                      # next
    return DEFAULT_PORT                            # lowest priority (fallback)

parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=None)
args = parser.parse_args([])
print(resolve_port(args.port))

Secrets Handling

Secrets (API keys, passwords, tokens) deserve stricter handling than ordinary configuration: never hardcode them in source, never commit them to version control (including inside a config file that gets committed), and prefer environment variables or a dedicated secrets manager over a plain config file for anything sensitive, since environment variables are easier to keep out of version control and to rotate per-deployment.

A .gitignore entry for any file that might hold real secrets (e.g. a local .env file, covered next) is a minimum safeguard, and secret values should never be logged or printed, even at debug log levels.

# .gitignore
.env
config.local.ini
*.secret

.env Files

A .env file holds KEY=value pairs meant to be loaded into the process environment for local development, so developers don't have to export variables manually in every shell session. Python's standard library has no built-in .env loader; the widely used third-party package python-dotenv fills this role.

python-dotenv is a third-party/ecosystem tool, not part of the standard library. In production deployments, environment variables are typically set directly by the hosting platform rather than loaded from a .env file at all.

# .env  (never committed to version control)
DATABASE_URL=postgres://localhost/mydb
API_KEY=dev-only-fake-key
# third-party: pip install python-dotenv
from dotenv import load_dotenv
import os

load_dotenv()   # reads .env and populates os.environ
db_url = os.environ["DATABASE_URL"]

Practical Usage

A small, layered configuration setup combining defaults, an optional file, and environment overrides:

Centralizing configuration loading into one function/object gives the rest of the program a single, typed, already-validated Settings object instead of scattering os.environ lookups everywhere.

import os
from dataclasses import dataclass

@dataclass
class Settings:
    database_url: str
    port: int
    debug: bool

def load_settings() -> Settings:
    return Settings(
        database_url=os.environ.get("DATABASE_URL", "sqlite:///local.db"),
        port=int(os.environ.get("PORT", "8080")),
        debug=os.environ.get("DEBUG", "false").lower() == "true",
    )

settings = load_settings()
print(settings)

Edge Cases

An environment variable set to an empty string (PORT=) is not the same as unset — os.environ.get("PORT") returns "", not None, so a naive truthiness check (if os.environ.get("PORT")) treats an explicitly-empty value the same as absent, which may or may not be the intended behavior. Boolean-like variables have no single standard string representation ("true", "1", "yes" all appear in the wild), so comparison logic should normalize case and accept the forms your deployment actually uses. Environment variables are inherited by child processes by default, which can leak sensitive values into subprocesses that don't need them unless deliberately filtered.

Best Practices

Centralize configuration loading into one place (a function or a settings object) rather than scattering os.environ lookups through the codebase. Fail fast and clearly on a missing required setting rather than silently defaulting. Keep secrets out of version control entirely — environment variables, a secrets manager, or an untracked local file, never a committed config file. Document every recognized environment variable somewhere a new developer will find it (README, or the settings object's own docstrings/type hints).

Complete Example

import os
from dataclasses import dataclass

@dataclass(frozen=True)
class Settings:
    api_key: str
    port: int
    debug: bool

class ConfigError(Exception):
    pass

def load_settings() -> Settings:
    api_key = os.environ.get("API_KEY")
    if not api_key:
        raise ConfigError("API_KEY environment variable is required")

    port = int(os.environ.get("PORT", "8080"))
    debug = os.environ.get("DEBUG", "false").strip().lower() in ("1", "true", "yes")

    return Settings(api_key=api_key, port=port, debug=debug)

if __name__ == "__main__":
    settings = load_settings()
    print(f"Starting on port {settings.port} (debug={settings.debug})")

Mental Model and Quick Reference

Think of configuration sources as a stack of overlapping answers to 'what should this setting be', checked from most specific to least specific: a command-line flag beats an environment variable, which beats a config file, which beats a hardcoded default — the first layer that actually answers wins.

os.environ["NAME"]              read, raises KeyError if unset
os.environ.get("NAME")           read, returns None if unset
os.environ.get("NAME", default)  read with a fallback
configparser                     standard library, INI files
tomllib                          standard library, read-only TOML (3.11+)
python-dotenv (third-party)      loads a .env file into os.environ

Precedence (low -> high):
  code default  ->  config file  ->  environment variable  ->  CLI argument

Key Takeaways

Externalizing configuration lets identical code behave correctly across development, testing, and production without edits.

os.environ values are always strings; explicit conversion is required for numbers and booleans.

configparser and tomllib are standard-library options for file-based configuration; YAML support and .env loading require third-party packages.

A sensible precedence order — defaults, then config file, then environment variables, then CLI arguments — lets more specific sources override more general ones.

Secrets belong in environment variables or a secrets manager, never hardcoded or committed to version control.

DATABASE_URL = "postgres://prod-user:realpassword@db/prod" # hardcoded in source import os DATABASE_URL = os.environ["DATABASE_URL"] # supplied by the deployment environment

Hardcoding a real credential in source code commits it to version control history permanently and forces a code change for every environment.

port = os.environ.get("PORT", 8080) # default is an int, env value is a str if port > 1000: # fine if PORT unset, but TypeError-prone once it's read from env port = int(os.environ.get("PORT", "8080")) # always converts consistently

Environment variable values are always strings; comparing a string PORT against an int default inconsistently can work by accident in one path and fail in another.

A committed .env file permanently exposes secrets in version control history, even if the file is later deleted or the secret rotated.

Why should configuration values like database URLs and API keys not be hardcoded in source code?

Hardcoding forces a code change for every different deployment context and, worse, permanently commits secrets into version control history — externalized configuration lets the same code run correctly everywhere it's deployed.

What type are all values read from os.environ, and what does that imply for a setting that represents a number or boolean?

All values are strings; a setting meant to be numeric or boolean must be explicitly converted (e.g. int(...), or comparing against known truthy strings) since there is no automatic type conversion.

What is the difference between os.environ["NAME"] and os.environ.get("NAME")?

os.environ["NAME"] raises KeyError if the variable is unset, which is appropriate for a required setting; os.environ.get("NAME") returns None (or a supplied default) instead, appropriate for an optional setting.

Describe a reasonable precedence order for defaults, config files, environment variables, and CLI arguments.

From lowest to highest priority: built-in code defaults, then a configuration file, then environment variables, then command-line arguments — each more specific source overrides the more general ones beneath it.

Is python-dotenv part of the Python standard library?

No — it's a third-party package; the standard library has no built-in .env file loader, though configparser and (read-only, 3.11+) tomllib do handle other config file formats.

Why is checking if os.environ.get("PORT") for truthiness potentially wrong when PORT is explicitly set to an empty string?

An empty string is falsy in Python, so this check treats an explicitly-set-but-empty PORT the same as an unset one, which may silently apply the wrong fallback instead of surfacing a misconfiguration.

Write a function get_log_level() that reads the LOG_LEVEL environment variable, defaults to 'info' if unset, and normalizes the result to lowercase.

import os def get_log_level() -> str: return os.environ.get("LOG_LEVEL", "info").lower() os.environ.get with a default handles the unset case;.lower() normalizes whatever casing the environment variable was set with, so callers can compare against a consistent lowercase value.

What happens when this code runs, and why?

It raises a TypeError. os.environ.get returns the string "3" (since RETRIES is set), and "3" + 1 tries to add a str and an int, which Python's strong typing does not silently coerce — the code needed int(os.environ.get("RETRIES", "5")) to work correctly.

Write a function resolve_setting that takes a cli_value, an env_var name, and a default, returning the cli_value if given, else the environment variable if set, else the default — implementing a simple precedence chain.

import os from typing import Optional def resolve_setting(cli_value: Optional[str], env_var: str, default: str) -> str: if cli_value is not None: return cli_value env_value = os.environ.get(env_var) if env_value is not None: return env_value return default

The function checks sources in priority order — an explicit CLI value wins first, then the environment variable if present, and finally the hardcoded default — implementing a minimal version of the standard configuration precedence chain.

import os

os.environ["RETRIES"] = "3"
value = os.environ.get("RETRIES", 5)
print(value + 1)