For years, structured logging in Go meant picking a third-party library, wiring it through your codebase, and quietly accepting that you'd married it. I'd been on the same one across several services, and it was fine, but it was a dependency, it had opinions, and migrating off it would have been a project. Then log/slog landed in the standard library with Go 1.21, and after using it for a while I went back and deleted my logging wrapper. That felt good.
The pitch for structured logging hasn't changed: you log key-value pairs, not formatted sentences, so machines can parse what your service is doing without regex archaeology. log.Printf("user %s did %s", id, action) is unparseable at scale. The structured equivalent gives you fields you can filter, aggregate and alert on. What's new is that you no longer need a dependency to do it well.
Here's the shape of it:
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("request handled",
"method", r.Method,
"path", r.URL.Path,
"status", status,
"duration_ms", elapsed.Milliseconds(),
)
That emits a single JSON object per line, every field queryable. Swap NewJSONHandler for NewTextHandler and you get human-readable key=value output for local development, same call sites, no code changes. That handler split is the bit I appreciate most: the logger decides what to record, the handler decides how it comes out, and you choose the handler per environment.
Two features moved me over for good. First, With lets you bind context onto a logger once and have it appear on every subsequent line:
reqLogger := logger.With("request_id", reqID, "user", userID)
reqLogger.Info("started")
// ... later ...
reqLogger.Error("backend failed", "err", err)
Both lines carry the request ID and user without me repeating them. Across a request handler that's the difference between logs you can correlate and logs you can't.
Second, it threads through context. slog.InfoContext(ctx, ...) and a custom handler let you pull request-scoped values, a trace ID, say, out of the context and onto the log line automatically, so the correlation happens without every call site remembering to add it.
It is not perfect. The variadic key-value pairs are easy to get wrong: miss one and you get a stray !BADKEY in the output rather than a compile error. You can use the typed slog.String, slog.Int and friends to make it explicit and safer, at the cost of more verbosity. I use the loose form for quick logs and the typed form on the hot paths I care about. And being newer, the ecosystem of handlers is still catching up to what the established libraries offer.
But it's in the standard library. No dependency to vet, version or migrate off. It's the lingua franca now, the thing every Go service can be expected to speak, which means handlers and tooling will converge on it rather than fragmenting across five competing loggers. For a new service I'd reach for nothing else, and for the old ones, deleting the wrapper and the dependency it dragged along was one of the more satisfying afternoons I've had this year. Less code, fewer dependencies, better logs. That's the whole trade, and it's a good one.