Configuration, secrets & structured logging
Load typed config from the environment, keep secrets safe, and log structured records.
Config lives in the environment, not in your code
A service that hard-codes its port, database URL, or feature flags can only run in one place. Twelve-factor apps push all of that into environment variables so a single build artifact runs unchanged in dev, staging, and prod. You change behavior by changing the environment, never by editing and redeploying code. That is also why the demo's load_config takes an env dict as an argument instead of reaching into os.environ directly: passing the environment in keeps the function pure and trivial to test.
Environment values are always strings
os.environ (and the env dict here) maps strings to strings. There are no ints, bools, or lists inside it. Reading config is therefore two steps: supply a default for missing keys, then coerce the string into the type you actually want.
port = int(env.get("PORT", "8000")) # "9000" becomes 9000
debug = env.get("DEBUG", "false").lower() == "true"
env.get("PORT", "8000") returns the default only when "PORT" is absent. If the key exists, you get its string value and must convert it yourself.
Secrets: record presence, never the value
API keys, tokens, and passwords come from the environment too, but they must never appear in source control or in logs. The safe pattern is to log whether a secret is configured, not what it is:
has_secret = "SECRET" in env # a True/False flag is safe to emit; the value is not
"SECRET" in env tests key membership, so it stays False until the key exists and never reads the value.
Structured logging
Log machine-readable records, not sentences. Key/value fields let a log system filter and aggregate (for example, find every startup where debug was True):
import logging
logger = logging.getLogger(__name__)
logger.info("startup", extra={"port": port, "debug": debug})
The extra fields attach to the log record, but they only surface if your handler uses a structured (often JSON) formatter. The default formatter prints just the message text.
Pitfalls
bool("false")isTrue. Every non-empty string is truthy, so never coerce a flag withbool(...). Compare the lowered string explicitly, as above.int(env.get("PORT", "8000"))raisesValueErrorifPORTis set to""or"abc". A present-but-empty variable is not the same as a missing one, and.getonly defends against the missing case.- Reusing a reserved field name in
extra(likemessageorname) raisesKeyError. Choose your own keys.
Interview nuance: interviewers probe why config should be loaded once, at startup, into a typed object rather than read from os.environ all over the codebase. Loading once gives you a single place to validate and fail fast, makes the code testable (you inject an env dict, exactly like this exercise), and guarantees every request sees one consistent snapshot instead of values that could change mid-run.
def load_config(env):
port = int(env.get("PORT", "8000"))
debug = env.get("DEBUG", "false").lower() == "true"
return {"port": port, "debug": debug, "has_secret": "SECRET" in env}
print(load_config({"PORT": "9000", "DEBUG": "true", "SECRET": "x"}))Apply
Your turn
The task this lesson builds to.
Warm-up (one file): implement load_config(env) to turn an env dict into
{"port": int, "debug": bool, "has_secret": bool}. Default PORT to 8000 and DEBUG to off.
debug is True only when DEBUG is "true" (any case). has_secret records whether
"SECRET" is present (never its value).
load_config({}) is {"port": 8000, "debug": False, "has_secret": False}.
3 hints and 3 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Implement load_config(env) in config/settings.py: use the read-only DEFAULTS for PORT
and DEBUG, coerce PORT to int and DEBUG to bool ("true" case-insensitive), and set
has_secret from whether "SECRET" is present, never its value. Some tests are hidden.
3 hints and 2 automated checks are waiting in the workspace.