Python Environment Variables Practice Problems & Exercises
Practice: Environment Variables
← Back to lessonEasy
Predict the output. Four ways to read an environment variable that does not exist — which raise, which return defaults?
import os
# Remove if accidentally set
os.environ.pop("FAKE_PORT_XYZ", None)
# Method 1: .get() with no default returns None
result1 = os.environ.get("FAKE_PORT_XYZ")
print(result1)
# Method 2: .get() with explicit default
result2 = os.environ.get("FAKE_PORT_XYZ", "8080")
print(result2)
# Method 3: os.getenv() with default — same as .get()
result3 = os.getenv("FAKE_PORT_XYZ", "8080")
print(result3)
# Method 4: direct access raises KeyError
try:
_ = os.environ["FAKE_PORT_XYZ"]
except KeyError:
print(True)Solution
import os
os.environ.pop("FAKE_PORT_XYZ", None)
result1 = os.environ.get("FAKE_PORT_XYZ")
print(result1)
result2 = os.environ.get("FAKE_PORT_XYZ", "8080")
print(result2)
result3 = os.getenv("FAKE_PORT_XYZ", "8080")
print(result3)
try:
_ = os.environ["FAKE_PORT_XYZ"]
except KeyError:
print(True)
Output:
None
8080
8080
True
How it works: os.environ.get("KEY") with no default returns None when the key is absent — same as any dict. Passing a second argument returns that default string. os.getenv("KEY", default) is syntactic sugar that calls os.environ.get(key, default) internally — the CPython source for os.getenv is literally one line. Direct bracket access os.environ["KEY"] raises KeyError on a missing key, matching standard dict semantics.
Key insight: Use os.environ["KEY"] for variables that must exist — it fails loudly with a clear KeyError at startup rather than silently propagating None through application logic. Use os.environ.get("KEY", "default") for optional variables with sensible defaults. The choice communicates intent: required vs optional.
Expected Output
None\n8080\n8080\nTrueHints
Hint 1: os.environ.get() and os.getenv() are identical in behavior — both return None or a default if the key is missing.
Hint 2: os.environ["KEY"] raises KeyError if the key is not in the environment.
Hint 3: os.getenv() is just a convenience alias for os.environ.get().
Predict the output. A common production bug: using bool() on an environment variable string.
import os
os.environ["DEBUG"] = "false"
# THE BUG: bool() on a non-empty string is ALWAYS True
debug_wrong = bool(os.environ.get("DEBUG", "false"))
print(debug_wrong)
# Also wrong for the same reason
debug_also_wrong = bool("false")
print(debug_also_wrong)
# THE FIX: compare the string explicitly
debug_correct = os.environ.get("DEBUG", "false").lower() in ("true", "1", "yes", "on")
print(debug_correct)
# Now set it to "true" and verify
os.environ["DEBUG"] = "true"
debug_true = os.environ.get("DEBUG", "false").lower() in ("true", "1", "yes", "on")
print(debug_true)
del os.environ["DEBUG"]Solution
import os
os.environ["DEBUG"] = "false"
debug_wrong = bool(os.environ.get("DEBUG", "false"))
print(debug_wrong)
debug_also_wrong = bool("false")
print(debug_also_wrong)
debug_correct = os.environ.get("DEBUG", "false").lower() in ("true", "1", "yes", "on")
print(debug_correct)
os.environ["DEBUG"] = "true"
debug_true = os.environ.get("DEBUG", "false").lower() in ("true", "1", "yes", "on")
print(debug_true)
del os.environ["DEBUG"]
Output:
True
True
False
True
How it works: Python's bool() on a string tests whether the string is non-empty — not whether its content means "true". The string "false" has 5 characters, so bool("false") is True. This is the single most common environment variable bug in Python code, and it silently enables debug mode in production when DEBUG=false is set.
The correct pattern checks the string's content: "false".lower() in ("true", "1", "yes", "on") evaluates to False because "false" is not in the tuple of truthy strings.
Key insight: Python truthiness (bool(x)) is about emptiness for strings, not semantic meaning. Always compare environment variable strings explicitly. The set ("true", "1", "yes", "on") mirrors the conventions of many shells, config parsers, and frameworks — using all of them makes your code interoperate correctly.
Expected Output
True\nTrue\nFalse\nTrueHints
Hint 1: In Python, every non-empty string is truthy — bool("false") is True, not False.
Hint 2: To coerce a boolean env var correctly, compare the string value explicitly.
Hint 3: The canonical pattern is: value.lower() in ("true", "1", "yes", "on")
Convert environment variable strings to typed values. Demonstrate what happens when the value is not a valid integer.
import os
# Set up test environment
os.environ["PORT"] = "9000"
os.environ["TIMEOUT"] = "0.5"
# All env vars are strings — convert explicitly
port_str = os.environ.get("PORT", "8080")
port = int(port_str)
print(port)
print(type(port))
timeout_str = os.environ.get("TIMEOUT", "30.0")
timeout = float(timeout_str)
print(timeout)
print(type(timeout))
# What happens with an invalid value?
os.environ["PORT"] = "not_a_number"
try:
bad_port = int(os.environ.get("PORT", "8080"))
except ValueError:
print("ValueError caught: PORT")
# Clean up
del os.environ["PORT"]
del os.environ["TIMEOUT"]Solution
import os
os.environ["PORT"] = "9000"
os.environ["TIMEOUT"] = "0.5"
port_str = os.environ.get("PORT", "8080")
port = int(port_str)
print(port)
print(type(port))
timeout_str = os.environ.get("TIMEOUT", "30.0")
timeout = float(timeout_str)
print(timeout)
print(type(timeout))
os.environ["PORT"] = "not_a_number"
try:
bad_port = int(os.environ.get("PORT", "8080"))
except ValueError:
print("ValueError caught: PORT")
del os.environ["PORT"]
del os.environ["TIMEOUT"]
Output:
9000
<class 'int'>
0.5
<class 'float'>
ValueError caught: PORT
How it works: The operating system stores all environment variables as raw byte sequences — there is no type metadata. Python's os.environ exposes them as strings. You must call int(), float(), or your own conversion logic to get typed values. int("not_a_number") raises ValueError because the string is not a valid integer literal.
Key insight: Always validate conversions at startup, not at the point of use. A misconfigured PORT=abc should crash immediately with a helpful message, not fail mysteriously three hours later when the server tries to bind. The fail-fast pattern is: read all env vars, convert with error handling, collect all errors, then exit if any errors exist. This is exactly what Pydantic Settings automates.
Expected Output
9000\n<class 'int'>\n0.5\n<class 'float'>\nValueError caught: PORTHints
Hint 1: os.environ always stores and returns strings — the OS has no concept of integer environment variables.
Hint 2: You must convert with int() or float() manually; wrap in try/except for invalid values.
Hint 3: Provide a string default to os.environ.get(), then convert the result.
Explore the nature of os.environ. It is dict-like but not a dict, and it reflects the live process environment.
import os
# os.environ is NOT a plain dict
print(isinstance(os.environ, dict))
# But you can set and read it like a dict
os.environ["MY_TEST_VAR"] = "hello"
print(os.environ["MY_TEST_VAR"])
# And the change is immediately visible to child processes
import subprocess
result = subprocess.run(
["python3", "-c", "import os; print(os.environ.get('MY_TEST_VAR', 'missing'))"],
capture_output=True,
text=True,
)
print(result.stdout.strip())
# Deleting works too
del os.environ["MY_TEST_VAR"]
print("MY_TEST_VAR" not in os.environ)Solution
import os
print(isinstance(os.environ, dict))
os.environ["MY_TEST_VAR"] = "hello"
print(os.environ["MY_TEST_VAR"])
import subprocess
result = subprocess.run(
["python3", "-c", "import os; print(os.environ.get('MY_TEST_VAR', 'missing'))"],
capture_output=True,
text=True,
)
print(result.stdout.strip())
del os.environ["MY_TEST_VAR"]
print("MY_TEST_VAR" not in os.environ)
Output:
False
hello
hello
True
How it works: os.environ is os._Environ, a MutableMapping that wraps the C-level process environment block. It is not a Python dict copy — every read and write goes directly to the OS's environment storage. Setting a variable immediately makes it visible to any subprocess launched after that point, because child processes inherit a copy of the parent's current environment.
del os.environ["MY_TEST_VAR"] calls os.unsetenv() under the hood, which removes the variable from the process environment block entirely.
Key insight: os.environ mutations are process-global and irreversible within the process's lifetime (without explicit cleanup). In test suites, this means one test setting os.environ["DATABASE_URL"] can silently pollute subsequent tests. Always clean up with del os.environ["KEY"] or use a context manager that restores the original state. The unittest.mock.patch.dict(os.environ, {...}) pattern handles this automatically.
Expected Output
False\nhello\nhello\nTrueHints
Hint 1: os.environ is an instance of os._Environ, not dict — isinstance(os.environ, dict) is False.
Hint 2: Setting os.environ["KEY"] = value immediately updates the live process environment.
Hint 3: Child processes spawned after a os.environ assignment inherit the new value.
Medium
Demonstrate the initialization trap. Show why reading at import time causes stale values, and how call-time reading avoids it.
import os
# Simulate the anti-pattern: captured at "import time"
os.environ["APP_MODE"] = "old_value"
# Module-level assignment — captured once
APP_MODE_CACHED = os.environ.get("APP_MODE", "default")
def get_mode_wrong():
"""Returns the value captured at module load time — stale!"""
return APP_MODE_CACHED
def get_mode_correct():
"""Reads the current value on every call — always fresh."""
return os.environ.get("APP_MODE", "default")
# Both agree before the change
print(get_mode_wrong())
# Now "someone" updates the environment (e.g., test setup)
os.environ["APP_MODE"] = "new_value"
# Wrong: still returns old value
print(get_mode_wrong())
# Correct: returns new value
print(get_mode_correct())
del os.environ["APP_MODE"]Solution
import os
os.environ["APP_MODE"] = "old_value"
APP_MODE_CACHED = os.environ.get("APP_MODE", "default")
def get_mode_wrong():
return APP_MODE_CACHED
def get_mode_correct():
return os.environ.get("APP_MODE", "default")
print(get_mode_wrong())
os.environ["APP_MODE"] = "new_value"
print(get_mode_wrong())
print(get_mode_correct())
del os.environ["APP_MODE"]
Output:
old_value
old_value
new_value
How it works: APP_MODE_CACHED = os.environ.get("APP_MODE", "default") executes once when the module is first loaded. It stores the string "old_value" in the module-level variable. Later changes to os.environ["APP_MODE"] have no effect on APP_MODE_CACHED — it is a plain Python string, not a reference to the environment.
get_mode_correct() calls os.environ.get() on every invocation, so it always reads the current process environment value.
Key insight: This bug is common in test suites: the test file imports the module (capturing the old env var), then sets the env var for a specific test. The module sees the old value forever. The fix is to read inside functions. Exception: it is acceptable to read at module level for values that are genuinely process-lifetime constants — ENV = os.environ.get("APP_ENV", "development") is fine because you never expect it to change mid-process. The rule: if the value could differ between test runs or be rotated at runtime, read at call time.
Expected Output
old_value\nold_value\nnew_valueHints
Hint 1: A module-level variable captures the env var value at import time — it does not update when os.environ changes later.
Hint 2: Reading inside a function reads the current value every time the function is called.
Hint 3: The fix: read os.environ inside the function body, not at module level.
Implement three type-safe helper functions for reading integer, boolean, and list environment variables. Validate that they fail with clear error messages on bad input.
import os
def get_env_int(key, default=None):
"""Read an integer env var. Raises ValueError with a clear message on bad input."""
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"{key} must be an integer, got: {value!r}")
def get_env_bool(key, default=False):
"""Read a boolean env var. Compares string content — never uses bool() directly."""
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 env var. Strips whitespace from each item."""
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()]
# Test cases
os.environ["PORT"] = "9000"
os.environ["DEBUG"] = "true"
os.environ["ALLOWED_HOSTS"] = "api.example.com, example.com, localhost"
os.environ["WORKERS"] = "not_a_number"
print(get_env_int("PORT", default=8080))
print(get_env_bool("DEBUG"))
print(get_env_list("ALLOWED_HOSTS"))
try:
get_env_int("WORKERS")
except ValueError as e:
print(f"ValueError: {e}")
for k in ("PORT", "DEBUG", "ALLOWED_HOSTS", "WORKERS"):
os.environ.pop(k, None)Solution
import os
def get_env_int(key, default=None):
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"{key} must be an integer, got: {value!r}")
def get_env_bool(key, default=False):
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):
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()]
os.environ["PORT"] = "9000"
os.environ["DEBUG"] = "true"
os.environ["ALLOWED_HOSTS"] = "api.example.com, example.com, localhost"
os.environ["WORKERS"] = "not_a_number"
print(get_env_int("PORT", default=8080))
print(get_env_bool("DEBUG"))
print(get_env_list("ALLOWED_HOSTS"))
try:
get_env_int("WORKERS")
except ValueError as e:
print(f"ValueError: {e}")
for k in ("PORT", "DEBUG", "ALLOWED_HOSTS", "WORKERS"):
os.environ.pop(k, None)
Output:
9000
True
['api.example.com', 'example.com', 'localhost']
ValueError: WORKERS must be an integer, got: 'not_a_number'
How it works:
get_env_intchecks forNonefirst (variable absent vs variable present but empty are different cases), then converts, then raises a named error identifying the failing variable.get_env_booluses the.strip().lower() in (...)pattern — safe againstDEBUG=True(capital T),DEBUG= true(leading space), andDEBUG=1(numeric).get_env_listsplits on the separator and strips whitespace from each item, so"a, b, c"becomes["a", "b", "c"]correctly.
Key insight: These three helpers cover roughly 80% of real-world environment variable reading needs. The pattern — validate at read time, name the failing variable in the error message, give a typed result — is exactly what Pydantic Settings automates. Writing them manually first builds the intuition for why Pydantic Settings exists and what problem it solves.
Expected Output
9000\nTrue\n['api.example.com', 'example.com', 'localhost']\nValueError: WORKERS must be an integerHints
Hint 1: Build helper functions that handle conversion, default values, and clear error messages in one place.
Hint 2: get_env_bool should compare the string value, never use bool() directly.
Hint 3: get_env_list should split on a separator and strip whitespace from each item.
Implement a minimal .env file loader that mirrors python-dotenv's non-override behavior. Parse a .env string and set variables that are not already in the environment.
import os
def parse_dotenv(env_content, override=False):
"""
Parse a .env file string and set variables in os.environ.
By default, does NOT override variables already in the environment
(mirrors python-dotenv's load_dotenv() behavior).
"""
for line in env_content.splitlines():
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith("#"):
continue
# Split on the first = only
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
# Strip optional surrounding quotes
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
# Only set if not already present (unless override=True)
if override or key not in os.environ:
os.environ[key] = value
# Sample .env content
dotenv_content = """
# Database configuration
DATABASE_URL=postgresql://localhost:5432/myapp_dev
SECRET_KEY=dev-secret-key
DEBUG=true
PORT=8000
"""
# Pre-set one variable to test non-override behavior
os.environ["PORT"] = "reloaded"
parse_dotenv(dotenv_content)
print(os.environ.get("DATABASE_URL"))
print(os.environ.get("SECRET_KEY"))
print(os.environ.get("DEBUG"))
# PORT should NOT be overridden — we set it to "reloaded" before loading
print(os.environ.get("PORT"))
# With override=True, PORT should be replaced
parse_dotenv(dotenv_content, override=True)
print(os.environ.get("PORT"))
for k in ("DATABASE_URL", "SECRET_KEY", "DEBUG", "PORT"):
os.environ.pop(k, None)Solution
import os
def parse_dotenv(env_content, override=False):
for line in env_content.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
if override or key not in os.environ:
os.environ[key] = value
dotenv_content = """
# Database configuration
DATABASE_URL=postgresql://localhost:5432/myapp_dev
SECRET_KEY=dev-secret-key
DEBUG=true
PORT=8000
"""
os.environ["PORT"] = "reloaded"
parse_dotenv(dotenv_content)
print(os.environ.get("DATABASE_URL"))
print(os.environ.get("SECRET_KEY"))
print(os.environ.get("DEBUG"))
print(os.environ.get("PORT"))
parse_dotenv(dotenv_content, override=True)
print(os.environ.get("PORT"))
for k in ("DATABASE_URL", "SECRET_KEY", "DEBUG", "PORT"):
os.environ.pop(k, None)
Output:
postgresql://localhost:5432/myapp_dev
dev-secret-key
true
reloaded
8000
How it works: The parser skips blank lines and #-prefixed comments, splits on the first = using str.partition() (which handles values containing =), and optionally strips surrounding quotes. The critical behavior: if override or key not in os.environ — when override=False, a key already in os.environ is left untouched. This is the contract that makes the same code work in both development (.env sets everything) and production (real environment variables win).
Key insight: This non-override default is the 12-factor app design: your deployment platform (Kubernetes, Heroku, Render) sets the real values. load_dotenv() is a no-op in production because every key it tries to set is already present. In development, the shell has no variables set, so .env fills them all. The same code path, zero branching on environment.
Expected Output
postgresql://localhost:5432/myapp_dev\ndev-secret-key\ntrue\n8000\nreloadedHints
Hint 1: A .env file is KEY=VALUE pairs, one per line. Strip whitespace and skip comment lines starting with #.
Hint 2: load_dotenv does NOT override existing environment variables — it skips keys already in os.environ.
Hint 3: To implement override behavior, set the value unconditionally instead of checking first.
Build a fail-fast config validator that collects all errors in one pass and reports them together, then returns a typed config dict on success.
import os
def validate_config(env=None):
"""
Validate environment variables.
Returns (config_dict, errors_list).
config_dict is None if any errors exist.
"""
if env is None:
env = os.environ
errors = []
config = {}
def require(key):
val = env.get(key)
if not val:
errors.append(f" {key}: required but not set")
return None
return val
def get_int(key, default=None, min_val=None, max_val=None):
val = env.get(key)
if val is None:
return default
try:
n = int(val)
except ValueError:
errors.append(f" {key}: must be an integer, got {val!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
config["database_url"] = require("DATABASE_URL")
config["secret_key"] = require("SECRET_KEY")
config["port"] = get_int("PORT", default=8000, min_val=1, max_val=65535)
config["debug"] = env.get("DEBUG", "false").lower() in ("true", "1", "yes", "on")
return (None if errors else config), errors
# Test 1: missing required vars + invalid type
fake_env_bad = {"PORT": "abc", "SECRET_KEY": "my-secret"}
config, errors = validate_config(fake_env_bad)
print(f"{len(errors)} errors")
for e in errors:
print(e)
# Test 2: valid config
fake_env_good = {
"DATABASE_URL": "postgresql://localhost/mydb",
"SECRET_KEY": "supersecretkey",
"PORT": "8000",
"DEBUG": "false",
}
config, errors = validate_config(fake_env_good)
print(f"Configuration OK: port={config['port']} debug={config['debug']}")Solution
import os
def validate_config(env=None):
if env is None:
env = os.environ
errors = []
config = {}
def require(key):
val = env.get(key)
if not val:
errors.append(f" {key}: required but not set")
return None
return val
def get_int(key, default=None, min_val=None, max_val=None):
val = env.get(key)
if val is None:
return default
try:
n = int(val)
except ValueError:
errors.append(f" {key}: must be an integer, got {val!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
config["database_url"] = require("DATABASE_URL")
config["secret_key"] = require("SECRET_KEY")
config["port"] = get_int("PORT", default=8000, min_val=1, max_val=65535)
config["debug"] = env.get("DEBUG", "false").lower() in ("true", "1", "yes", "on")
return (None if errors else config), errors
fake_env_bad = {"PORT": "abc", "SECRET_KEY": "my-secret"}
config, errors = validate_config(fake_env_bad)
print(f"{len(errors)} errors")
for e in errors:
print(e)
fake_env_good = {
"DATABASE_URL": "postgresql://localhost/mydb",
"SECRET_KEY": "supersecretkey",
"PORT": "8000",
"DEBUG": "false",
}
config, errors = validate_config(fake_env_good)
print(f"Configuration OK: port={config['port']} debug={config['debug']}")
Output:
2 errors
DATABASE_URL: required but not set
PORT: must be an integer, got 'abc'
Configuration OK: port=8000 debug=False
How it works: The validator collects all errors into a list instead of raising on the first failure. This is the right UX: an operator fixing a misconfigured deployment needs to see all problems at once, not fix one, redeploy, discover the next, and repeat. The inner helper functions share the errors list via closure — they append to it but let the caller decide what to do.
The function accepts an env parameter (defaulting to os.environ) so it can be tested with a plain dict without touching the process environment.
Key insight: The "collect all errors, report all at once" pattern is the difference between a tool that respects the operator's time and one that doesn't. Pydantic Settings does exactly this — it collects every field validation error and raises a single ValidationError with all of them listed. Always design startup validators this way.
Expected Output
2 errors\n DATABASE_URL: required but not set\n PORT: must be an integer, got 'abc'\nConfiguration OK: port=8000 debug=FalseHints
Hint 1: Collect all errors first, then report them all at once — never fail on the first error only.
Hint 2: A config validator should check for missing required vars AND invalid types AND business rules (e.g., port range).
Hint 3: Return a typed config dict on success — that is the point of validation.
Hard
Implement a 12-factor app configuration module that reads all config from environment variables, provides typed access, and separates required from optional variables.
import os
class AppConfig:
"""
12-factor app configuration.
All values sourced from environment variables.
Required vars raise KeyError at construction time if absent.
"""
def __init__(self, env=None):
e = env if env is not None else os.environ
# Required — will raise KeyError with clear message if absent
try:
self.database_url = e["DATABASE_URL"]
self.secret_key = e["SECRET_KEY"]
except KeyError as missing:
raise KeyError(
f"Required environment variable {missing} is not set. "
f"Check your .env file or deployment configuration."
) from None
# Optional with typed defaults
self.env = e.get("APP_ENV", "development")
self.debug = e.get("DEBUG", "false").strip().lower() in ("true", "1", "yes", "on")
self.port = int(e.get("PORT", "8000"))
self.workers = int(e.get("WORKERS", "1"))
self.log_level = e.get("LOG_LEVEL", "INFO").upper()
self.redis_url = e.get("REDIS_URL", "redis://localhost:6379/0")
@property
def is_production(self):
return self.env == "production"
@property
def is_development(self):
return self.env == "development"
def safe_repr(self):
"""Return config as string with secrets redacted — safe to log."""
return (
f"AppConfig(env={self.env!r}, debug={self.debug}, "
f"port={self.port}, log_level={self.log_level!r}, "
f"database_url='***REDACTED***', secret_key='***REDACTED***')"
)
# Test with a valid configuration
test_env = {
"DATABASE_URL": "postgresql://localhost:5432/appdb",
"SECRET_KEY": "my-secret-key-here",
"REDIS_URL": "redis://localhost:6379/0",
# APP_ENV, DEBUG, PORT, LOG_LEVEL use defaults
}
cfg = AppConfig(env=test_env)
print(cfg.env)
print(cfg.debug)
print(cfg.port)
print(cfg.log_level)
print(cfg.database_url)
print(cfg.redis_url)Solution
import os
class AppConfig:
def __init__(self, env=None):
e = env if env is not None else os.environ
try:
self.database_url = e["DATABASE_URL"]
self.secret_key = e["SECRET_KEY"]
except KeyError as missing:
raise KeyError(
f"Required environment variable {missing} is not set. "
f"Check your .env file or deployment configuration."
) from None
self.env = e.get("APP_ENV", "development")
self.debug = e.get("DEBUG", "false").strip().lower() in ("true", "1", "yes", "on")
self.port = int(e.get("PORT", "8000"))
self.workers = int(e.get("WORKERS", "1"))
self.log_level = e.get("LOG_LEVEL", "INFO").upper()
self.redis_url = e.get("REDIS_URL", "redis://localhost:6379/0")
@property
def is_production(self):
return self.env == "production"
@property
def is_development(self):
return self.env == "development"
def safe_repr(self):
return (
f"AppConfig(env={self.env!r}, debug={self.debug}, "
f"port={self.port}, log_level={self.log_level!r}, "
f"database_url='***REDACTED***', secret_key='***REDACTED***')"
)
test_env = {
"DATABASE_URL": "postgresql://localhost:5432/appdb",
"SECRET_KEY": "my-secret-key-here",
"REDIS_URL": "redis://localhost:6379/0",
}
cfg = AppConfig(env=test_env)
print(cfg.env)
print(cfg.debug)
print(cfg.port)
print(cfg.log_level)
print(cfg.database_url)
print(cfg.redis_url)
Output:
development
False
8000
INFO
postgresql://localhost:5432/appdb
redis://localhost:6379/0
How it works: AppConfig.__init__ reads all config once at construction time. Required variables use direct subscript access (e["KEY"]) so missing keys raise immediately. The from None in raise ... from None suppresses the original exception chain for a cleaner error message. Optional variables use .get() with typed defaults.
The safe_repr() method provides a log-safe representation — the most common logging mistake is log.info("Config: %s", vars(cfg)), which dumps SECRET_KEY and DATABASE_URL (containing the password) into log files.
Key insight: This class IS the documentation for what environment variables your application needs. One __init__ method tells an engineer exactly which variables to set, which are required, and what the defaults are. Compare this to a codebase where os.environ.get("KEY") is scattered across 50 files — the 12-factor approach makes configuration auditable and self-documenting.
Expected Output
development\nFalse\n8000\nINFO\npostgresql://localhost:5432/appdb\nredis://localhost:6379/0Hints
Hint 1: The 12-factor pattern: every config value is read from the environment. No hardcoded values in application logic.
Hint 2: Build a single config object at startup — functions and classes read from it, not from os.environ directly.
Hint 3: Required variables (DATABASE_URL, SECRET_KEY) should crash clearly at startup if absent.
Build a secrets-safe config logger that redacts sensitive values before printing, and parses DATABASE_URL to redact only the credentials while keeping the host visible.
import os
from urllib.parse import urlparse, urlunparse
SENSITIVE_KEYS = frozenset({
"SECRET_KEY", "DATABASE_URL", "REDIS_URL",
"SMTP_PASSWORD", "API_KEY", "AUTH_TOKEN",
})
def redact_database_url(url):
"""Redact user:password from a database URL, keep host and db name."""
try:
parsed = urlparse(url)
if parsed.username or parsed.password:
# Replace credentials with ***:***
netloc = f"***:***@{parsed.hostname}"
if parsed.port:
netloc += f":{parsed.port}"
redacted = parsed._replace(netloc=netloc)
return urlunparse(redacted)
return url
except Exception:
return "***REDACTED***"
def safe_config_snapshot(env=None):
"""Return a dict of config values safe to log — secrets are redacted."""
if env is None:
env = os.environ
snapshot = {}
for key, value in env.items():
if key in SENSITIVE_KEYS:
if "URL" in key and "://" in value:
snapshot[key] = redact_database_url(value)
else:
snapshot[key] = "***REDACTED***"
else:
snapshot[key] = value
return snapshot
# Test setup
test_env = {
"SECRET_KEY": "super-secret-key-do-not-log",
"DATABASE_URL": "postgresql://admin:password123@prod-db:5432/appdb",
"PORT": "8001",
"DEBUG": "false",
}
snapshot = safe_config_snapshot(test_env)
# Verify secret is redacted
print(snapshot["SECRET_KEY"] == "***REDACTED***")
# Print the safe snapshot
for key in ("SECRET_KEY", "DATABASE_URL", "PORT", "DEBUG"):
print(f"{key}={snapshot[key]}")Solution
import os
from urllib.parse import urlparse, urlunparse
SENSITIVE_KEYS = frozenset({
"SECRET_KEY", "DATABASE_URL", "REDIS_URL",
"SMTP_PASSWORD", "API_KEY", "AUTH_TOKEN",
})
def redact_database_url(url):
try:
parsed = urlparse(url)
if parsed.username or parsed.password:
netloc = f"***:***@{parsed.hostname}"
if parsed.port:
netloc += f":{parsed.port}"
redacted = parsed._replace(netloc=netloc)
return urlunparse(redacted)
return url
except Exception:
return "***REDACTED***"
def safe_config_snapshot(env=None):
if env is None:
env = os.environ
snapshot = {}
for key, value in env.items():
if key in SENSITIVE_KEYS:
if "URL" in key and "://" in value:
snapshot[key] = redact_database_url(value)
else:
snapshot[key] = "***REDACTED***"
else:
snapshot[key] = value
return snapshot
test_env = {
"SECRET_KEY": "super-secret-key-do-not-log",
"DATABASE_URL": "postgresql://admin:password123@prod-db:5432/appdb",
"PORT": "8001",
"DEBUG": "false",
}
snapshot = safe_config_snapshot(test_env)
print(snapshot["SECRET_KEY"] == "***REDACTED***")
for key in ("SECRET_KEY", "DATABASE_URL", "PORT", "DEBUG"):
print(f"{key}={snapshot[key]}")
Output:
True
SECRET_KEY=***REDACTED***
DATABASE_URL=postgresql://***:***@prod-db:5432/appdb
PORT=8001
DEBUG=false
How it works: urlparse splits "postgresql://admin:password123@prod-db:5432/appdb" into components: scheme, netloc (credentials + host + port), path, etc. parsed._replace(netloc=...) creates a new ParseResult with the credentials replaced by ***:***. urlunparse reconstructs the full URL string. The host (prod-db:5432) and database name (appdb) are preserved — useful for debugging which database is being used without exposing the password.
The SENSITIVE_KEYS frozenset is the central registry of what to redact. Any key in this set is either fully redacted or URL-redacted.
Key insight: Credential leaks through logs are one of the top causes of security incidents. A DATABASE_URL like postgresql://user:password@host/db is a single string that, if logged, hands an attacker database credentials. The pattern here — a redaction layer between raw config and logging — is standard practice at production-grade organizations. The partial redaction of database URLs (keeping the host visible) is a deliberate trade-off: useful for debugging connection failures without leaking credentials.
Expected Output
True\nSECRET_KEY=***REDACTED***\nDATABASE_URL=postgresql://***:***@prod-db:5432/appdb\nPORT=8001\nDEBUG=falseHints
Hint 1: Never log os.environ directly — it contains all secrets as plaintext.
Hint 2: A safe config snapshot redacts known-sensitive keys before logging.
Hint 3: For DATABASE_URL, you can parse the URL to redact credentials while keeping the host visible.
Implement a multi-environment config with cross-field validation that enforces stricter requirements in production — matching the pattern used in real FastAPI applications.
import os
class MultiEnvConfig:
"""
Configuration with environment-specific validation rules.
Production enforces: debug=False, long SECRET_KEY.
Development is permissive for developer convenience.
"""
MIN_SECRET_KEY_LEN_PROD = 32
def __init__(self, env=None):
e = env if env is not None else os.environ
errors = []
self.app_env = e.get("APP_ENV", "development")
self.debug = e.get("DEBUG", "false").strip().lower() in ("true", "1", "yes", "on")
self.port = int(e.get("PORT", "8000"))
self.secret_key = e.get("SECRET_KEY", "dev-secret")
self.database_url = e.get(
"DATABASE_URL", "postgresql://localhost:5432/appdb"
)
# Cross-field validation for production
if self.app_env == "production":
if self.debug:
errors.append("DEBUG must be False in production")
sk_len = len(self.secret_key)
if sk_len < self.MIN_SECRET_KEY_LEN_PROD:
errors.append(
f"SECRET_KEY too short for production "
f"(got {sk_len} chars, need >= {self.MIN_SECRET_KEY_LEN_PROD})"
)
if errors:
raise ValueError("; ".join(errors))
def __repr__(self):
return f"env={self.app_env} debug={self.debug} port={self.port}"
# Test 1: valid development config
dev_cfg = MultiEnvConfig({"APP_ENV": "development", "DEBUG": "true", "PORT": "8000"})
print(dev_cfg)
# Test 2: valid production config
prod_cfg = MultiEnvConfig({
"APP_ENV": "production",
"DEBUG": "false",
"PORT": "443",
"SECRET_KEY": "a" * 32,
"DATABASE_URL": "postgresql://prod-host:5432/proddb",
})
print(prod_cfg)
# Test 3: production with debug=True — should raise
try:
MultiEnvConfig({"APP_ENV": "production", "DEBUG": "true", "SECRET_KEY": "a" * 32})
except ValueError as e:
print(f"ValueError: {e}")
# Test 4: production with short secret key — should raise
try:
MultiEnvConfig({"APP_ENV": "production", "DEBUG": "false", "SECRET_KEY": "tooshrt"})
except ValueError as e:
print(f"ValueError: {e}")Solution
import os
class MultiEnvConfig:
MIN_SECRET_KEY_LEN_PROD = 32
def __init__(self, env=None):
e = env if env is not None else os.environ
errors = []
self.app_env = e.get("APP_ENV", "development")
self.debug = e.get("DEBUG", "false").strip().lower() in ("true", "1", "yes", "on")
self.port = int(e.get("PORT", "8000"))
self.secret_key = e.get("SECRET_KEY", "dev-secret")
self.database_url = e.get(
"DATABASE_URL", "postgresql://localhost:5432/appdb"
)
if self.app_env == "production":
if self.debug:
errors.append("DEBUG must be False in production")
sk_len = len(self.secret_key)
if sk_len < self.MIN_SECRET_KEY_LEN_PROD:
errors.append(
f"SECRET_KEY too short for production "
f"(got {sk_len} chars, need >= {self.MIN_SECRET_KEY_LEN_PROD})"
)
if errors:
raise ValueError("; ".join(errors))
def __repr__(self):
return f"env={self.app_env} debug={self.debug} port={self.port}"
dev_cfg = MultiEnvConfig({"APP_ENV": "development", "DEBUG": "true", "PORT": "8000"})
print(dev_cfg)
prod_cfg = MultiEnvConfig({
"APP_ENV": "production",
"DEBUG": "false",
"PORT": "443",
"SECRET_KEY": "a" * 32,
"DATABASE_URL": "postgresql://prod-host:5432/proddb",
})
print(prod_cfg)
try:
MultiEnvConfig({"APP_ENV": "production", "DEBUG": "true", "SECRET_KEY": "a" * 32})
except ValueError as e:
print(f"ValueError: {e}")
try:
MultiEnvConfig({"APP_ENV": "production", "DEBUG": "false", "SECRET_KEY": "tooshrt"})
except ValueError as e:
print(f"ValueError: {e}")
Output:
env=development debug=True port=8000
env=production debug=False port=443
ValueError: DEBUG must be False in production
ValueError: SECRET_KEY too short for production (got 7 chars, need >= 32)
How it works: The constructor reads all fields first, then runs cross-field validation that can inspect multiple fields together. if self.app_env == "production" gates the stricter rules — development is permissive so engineers can iterate quickly. Production enforces real security requirements: no debug mode (which can expose stack traces in HTTP responses), and a secret key long enough to resist brute force.
Raising ValueError in __init__ means a misconfigured MultiEnvConfig() never returns a usable object — the process must either fix the configuration or not start.
Key insight: Cross-field validation is the key feature that manual os.environ.get() calls cannot easily provide. You cannot validate "DEBUG must be False when APP_ENV is production" at read time for each variable independently — you need all fields present first. This is precisely what Pydantic's @model_validator(mode="after") provides. Writing this class manually makes the Pydantic pattern's value immediately obvious: it solves the same problem with a tenth of the boilerplate and adds IDE autocomplete, type safety, and JSON schema generation for free.
Expected Output
env=development debug=True port=8000\nenv=production debug=False port=443\nValueError: DEBUG must be False in production\nValueError: SECRET_KEY too short for production (got 7 chars, need >= 32)Hints
Hint 1: Cross-field validation: some combinations are invalid (debug=True in production).
Hint 2: Production environments should have stricter requirements than development.
Hint 3: A model_validator that runs after all fields are set can enforce these rules.
