What does good structured logging actually look like, and why does it beat printf-style logs at scale?
Printf-style logs work fine with one service and ten users; once you have a dozen services and a real incident, only structured, correlatable JSON logs let you reconstruct what happened.
LEARN BY DOINGPut your intuition to work3 steps
What does good structured logging actually look like, and why does it beat printf-style logs at scale?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The problem with "print and grep"
Every backend engineer starts the same way: print(f"processing order {order_id}") or console.log("user logged in", userId). It works. You can grep the output, you can eyeball it in a terminal, and for a single process handling modest traffic it is genuinely fine. The moment you have more than one instance of a service — let alone a dozen microservices sitting behind a load balancer — that approach falls apart, not because logging stopped working, but because the shape of the data stopped matching how you actually need to use it.
Free-text logs are optimized for a human reading them live, one line at a time, in the terminal they were written from. They are terrible for the questions you actually ask during an incident: "show me every log line for this specific request, across every service it touched, in order." A printf line has no reliable fields to filter on — order_id might appear as order=123, orderId: 123, or #123 depending on which engineer wrote that particular log call last year. Extracting it means writing a regex, and every inconsistent format is another regex that breaks in production at 2 a.m.
#What "structured" actually means
Structured logging means every log entry is emitted as a self-describing record — almost always JSON in practice — with a consistent, machine-parseable set of fields, instead of a free-form sentence. At minimum, a good structured log line carries:
timestamp— always UTC, always a fixed format (ISO-8601 / RFC3339), never "however the local server clock formats it."level—debug/info/warn/error, as an actual enum, not embedded in the message text.serviceandlogger— which process and which component emitted the line.message— a short, human-readable, stable string (avoid interpolating variable data directly into it — that field should read the same for every occurrence of the same event).trace_id(orcorrelation_id) — the field that makes the other four worth having.- Arbitrary contextual fields —
order_id,user_id,amount_cents,latency_ms, whatever is relevant to that specific event — as their own keys, not interpolated into the message.
Once every log line is a record with named fields instead of a sentence, your log backend (Elasticsearch, Loki, CloudWatch Logs Insights, Datadog, whatever you use) can index and filter on those fields directly. "Show me every error log where service=payments and order_id=4471" becomes an actual query instead of a regex archaeology project.
#The correlation id is the whole point
A single field does more work than everything else on this list combined: a trace or correlation id generated once, at the edge of the system, and threaded through every downstream call and every log line that request touches. Whether you implement it as a custom X-Request-Id header, adopt the W3C traceparent format, or let an OpenTelemetry SDK generate it for you, the mechanism is the same — generate it if it doesn't already exist on the incoming request, put it in a context object (thread-local, contextvars, AsyncLocalStorage, a goroutine-scoped context, whatever your language offers), propagate it on every outbound call your service makes, and stamp it onto every single log line automatically, so individual engineers can't forget to include it.
With that one field in place, "what happened to this request" stops being a multi-hour archaeology exercise across six services' logs and becomes: filter every service's logs by trace_id=abc123, sort by timestamp, read the story top to bottom.
#A real implementation
Here's a minimal but production-shaped example in Python: a JSON formatter for the standard logging module, plus a contextvars-based trace id that survives across async boundaries without being passed explicitly through every function signature.
import loggingimport sysimport jsonimport uuidfrom contextvars import ContextVartrace_id_var: ContextVar[str] = ContextVar("trace_id", default="-")class JsonFormatter(logging.Formatter):def format(self, record: logging.LogRecord) -> str:payload = {"timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),"level": record.levelname.lower(),"logger": record.name,"service": "checkout-api","message": record.getMessage(),"trace_id": trace_id_var.get(),}for key, value in getattr(record, "extra_fields", {}).items():payload[key] = valueif record.exc_info:payload["exception"] = self.formatException(record.exc_info)return json.dumps(payload)handler = logging.StreamHandler(sys.stdout)handler.setFormatter(JsonFormatter())logger = logging.getLogger("checkout")logger.addHandler(handler)logger.setLevel(logging.INFO)def handle_checkout(order_id: str, amount_cents: int) -> None:trace_id_var.set(str(uuid.uuid4()))logger.info("order created",extra={"extra_fields": {"order_id": order_id, "amount_cents": amount_cents}},)try:charge_card(order_id, amount_cents)except PaymentDeclined as exc:logger.error("payment declined",extra={"extra_fields": {"order_id": order_id, "reason": str(exc)}},)raise
Which produces log lines like:
{"timestamp":"2026-08-21T14:03:11+0000","level":"error","logger":"checkout","service":"checkout-api","message":"payment declined","trace_id":"5f2b9e1a-3c1d-4b8a-9c2e-1a2b3c4d5e6f","order_id":"ORD-4471","reason":"insufficient_funds"}
Every field in that line is queryable on its own. That is the entire value proposition of structured logging: you traded a little bit of readability in a raw terminal for a massive amount of queryability in an aggregator, and at any real scale, that trade is not close.
#Printf logs vs. structured logs
| printf-style | structured (JSON) | |
|---|---|---|
| Filterable by field | No — needs regex, brittle | Yes — native query on any key |
| Cross-service correlation | Manual, error-prone | Automatic via trace_id |
Aggregation ("count errors by order_id") | Impractical | Trivial in Loki/ES/Datadog |
| Machine parsing cost | High (regex per format) | Zero (already a record) |
| Human readability, raw | Better | Slightly worse (mitigated by log viewers) |
| Cardinality control | Ad hoc | Enforced by schema/field naming conventions |
Good structured logging is also what makes logs and traces work together instead of being two disconnected tools: if your trace id is the same value in your logs as it is in your tracing backend (Jaeger, Tempo, X-Ray), you can jump from "this log line looks suspicious" straight to "here is the full distributed trace for that exact request," which is usually the fastest path through a real production incident.