Logging and config: the dot-name logger tree, JSON output, and settings that fail at boot
Loggers form a dot-name tree: configure the root once via dictConfig; libraries call getLogger(__name__) and add nothing. JSON logs plus contextvars request ids are queryable; lazy %s args skip work unless emitted; typed settings fail at boot, SecretStr hides secrets.
The Saturday the log bill arrived, the service had been “fine” for three weeks. During an incident someone flipped the root logger to DEBUG and never flipped it back; every handler in the codebase logged its full request payload with an f-string — logger.debug(f"req {body}"). Two failures compounded. DEBUG-in-prod meant every one of those lines was now emitted: 40 GB of logs per hour into a pipeline priced per ingested gigabyte, and the monthly bill grew a comma in a new place. Worse: f-strings format before logging decides anything, so for the three weeks prior to the flip, every request had been serializing a multi-kilobyte payload only to throw the string away — pure hot-path CPU tax. And the payloads carried emails and bearer tokens, so the cleanup ended in a PII audit across archived log backups. The remediation list was the whole discipline of this lesson: configure the root exactly once at the entrypoint, lazy %s arguments everywhere, JSON output with a correlation id, and a typed settings object that turns “DEBUG in prod” into a deploy-time error instead of a Saturday discovery.
One tree, configured once
logging.getLogger("app.api.orders") does not create an isolated logger — it creates (or returns, it is a registry of singletons) a node in a tree keyed by dot-name: app.api.orders is a child of app.api, which is a child of app, which hangs off the root. A record emitted at a node first passes that node’s level gate, then propagates up the tree and is offered to every ancestor’s handlers — ancestor levels are not re-checked, only their handlers fire. That asymmetry is the whole configuration contract: handlers belong at the root, configured once, at the process entrypoint; everything else just emits.
# main.py — the ONLY place in the process that configures logging
import logging.config
logging.config.dictConfig({
"version": 1,
"disable_existing_loggers": False, # keep library loggers alive
"formatters": {
"json": {"()": "pythonjsonlogger.json.JsonFormatter",
"format": "%(asctime)s %(levelname)s %(name)s %(message)s"},
},
"handlers": {"stdout": {"class": "logging.StreamHandler", "formatter": "json"}},
"root": {"level": "INFO", "handlers": ["stdout"]},
})
# every other module — app code and libraries alike
import logging
logger = logging.getLogger(__name__) # a tree node; attach NOTHING to itLibraries call getLogger(__name__) and add nothing — at most a NullHandler to silence the “no handlers found” warning. The classic violation: a library author “helpfully” attaches a StreamHandler at import time. Now every record is emitted by that handler and, after propagation, by the root handler — every line appears twice, and ops spends a week grepping duplicates before someone reads the library source. Per-module verbosity is still cheap when you need it: set loggers: {"app.api": {"level": "DEBUG"}} in the same dictConfig and only that subtree gets noisy.
Structured JSON and the correlation id
Prose logs can only be grepped; JSON logs can be queried — filter by level, group by logger, aggregate duration_ms percentiles, join on request_id. That last field is the one that turns fifty interleaved request streams into one readable story, and in async services it must come from contextvars, not threading.local — hundreds of tasks interleave on one thread, and a ContextVar is copied per task, so each request sees its own value (the same middleware seam you built in the python/06 request lifecycle):
import logging
from contextvars import ContextVar
request_id: ContextVar[str] = ContextVar("request_id", default="-")
class RequestIdFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = request_id.get() # stamped onto EVERY record
return True
# ASGI middleware sets it once per request
async def correlation(request, call_next):
request_id.set(request.headers.get("x-request-id") or new_id())
return await call_next(request)Two honest routes to JSON: python-json-logger is a formatter swap — five lines of dictConfig, stdlib semantics untouched. structlog is a second logging worldview — bound loggers carry context (log = log.bind(user_id=...)) through call chains without filters, which is genuinely better ergonomics, but adopting it halfway leaves two formats in one stream and a team confused about which import to use. Pick one posture and migrate whole services, not files.
Every payments-library log line appears twice in prod output. The library calls getLogger('payments') and also attaches its own StreamHandler at import. Root has the dictConfig handler. Why two lines?
Levels discipline and the lazy-formatting rule
logger.info("user %s rebalanced %d", uid, n) is not style — it is a deferral contract. The logging call first checks isEnabledFor(INFO) (roughly a hundred nanoseconds); only if the record will actually be emitted are the args formatted into the template. logger.debug(f"user {user!r}") inverts that: the f-string evaluates before the call, every time, even with DEBUG disabled — and a repr of a fat ORM object costs microseconds to milliseconds. At a few million hot-path calls per hour that is real CPU, and it was the silent three-week tax in the Hook. The %s form has a second payoff: the message template stays constant, so a log pipeline can group by template and graph “this line started firing 100x more” — f-strings produce a unique message per call and kill that aggregation.
Levels are a paging contract, not a severity vibe: ERROR means a human should act — if nobody should be woken, it is WARNING (degraded, self-healing, worth a morning look) or INFO (state changes: startup, config loaded, connection pool sized). Teams that log handled retries at ERROR train on-call to ignore ERROR, and the one real page drowns. DEBUG stays off in prod by default; when you need it, enable it for one dot-name subtree via dictConfig, never at root.
Settings that fail at boot
The config half of the discipline has one rule: a misconfigured service must refuse to start. pydantic-settings gives the mechanism — a typed class whose fields load from the environment and validate at construction:
from pydantic import PostgresDsn, SecretStr
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
model_config = {"env_prefix": "APP_"}
database_url: PostgresDsn # REQUIRED — no default, boot fails without it
api_key: SecretStr # repr and logs show '**********'
log_level: str = "INFO" # a default is fine when the default is the SAFE one
settings = Settings() # raises ValidationError AT BOOT, not at 3amThe config-drift failure this kills: a “sensible default” like database_url = "postgres://staging-db/app" means a prod deploy that forgets the env var boots green and quietly runs production traffic against staging — no crash, no page, just wrong data until an audit notices. Required fields must be required; the crash at deploy time is the feature. SecretStr closes the other leak: secrets never appear in repr, tracebacks, or the settings dump you log at startup (log the dump — it is the cheapest incident tool you will ever ship — but log it with secrets masked). One image, env-per-deployment: staging and prod differ only in environment variables, never in code paths.
Settings declares database_url: str = 'postgres://staging-db:5432/app' as a sensible default. A prod deploy forgets to set APP_DATABASE_URL. What happens?
- 01Walk the logger-tree contract: who configures what, how does propagation work, and what exactly goes wrong when a library attaches its own handler?
- 02Explain the lazy-formatting rule and the boot-time config posture: why %s args over f-strings, and why must required settings have no defaults?
Production logging is a tree you configure once. Every getLogger(__name__) call returns a singleton node keyed by dot-name; records pass their own level gate and then climb the tree, firing ancestor handlers without re-checking ancestor levels — which is why handlers live at the root, set up by one dictConfig call at the entrypoint, and why a library that attaches its own StreamHandler doubles every line in your output. Structure beats prose: JSON output turns grep into queries, and a contextvars-backed filter stamps an async-safe request_id on every record so fifty interleaved requests read as separate stories — threading.local cannot do this once tasks share a thread. Formatting is lazy by contract: %s args evaluate only if the record is emitted, an f-string pays its repr cost every call even at a disabled level — the silent CPU tax, and at DEBUG-in-prod the 40 GB-per-hour bill plus the PII audit from the Hook. Levels are a paging contract: ERROR wakes a human, WARNING waits for morning, and inflation trains on-call to ignore the one page that matters. Config mirrors the same fail-loud posture: a typed Settings validates at boot, required fields carry no defaults — a staging DSN default plus one forgotten env var is silent cross-environment traffic — and SecretStr keeps credentials out of reprs, tracebacks, and the startup dump you should absolutely be logging. Now when you see duplicate log lines, a mysteriously cheap Saturday bill, or a service that boots green against the wrong database — you will know which of the four rules to check first.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.