Switching to slog for structured logging
For a long time structured logging in Go meant pulling in a third-party library and accepting its opinions. Since log/slog landed in the standard library, I've been moving services onto it, and the migration is almost always a net simplification. Here's what the switch actually looks like and the two or three things that weren't obvious from the package docs.
The shape of it
slog separates the frontend (the Logger you call) from the backend (a Handler that decides format and destination). You pick a handler at startup and the call sites don't care. JSON for production, where a log pipeline ingests it:
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
slog.Info("server started", "addr", addr, "pid", os.Getpid())
That emits one JSON object per line, with addr and pid as real fields you can query on, not interpolated into a message string. The key insight that took me a beat: the arguments after the message are alternating key/value pairs. "addr", addr is one attribute.
Catching the mismatched-pair footgun
Because attributes are alternating key, value, key, value, it's easy to drop one and shift everything. slog tolerates it at runtime (it logs a !BADKEY), which is worse than failing. The fix is to use the typed slog.Attr constructors, which also skip a small reflection cost:
slog.Info("request done",
slog.String("method", r.Method),
slog.Int("status", status),
slog.Duration("took", time.Since(start)),
)
I lint for the loose string form in hot paths and use the slog.String/slog.Int style there. For throwaway debug lines the loose form is fine.
Context, and the request logger
The pattern I was missing coming from other loggers: With returns a child logger that carries attributes on every subsequent call. Combined with putting the logger in the request context, this gives you a request ID (or trace ID) on every line without threading it manually:
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := newID()
l := slog.With("request_id", reqID)
ctx := context.WithValue(r.Context(), loggerKey{}, l)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Pull it back out wherever you need it, and every line from that handler is automatically tagged:
l := r.Context().Value(loggerKey{}).(*slog.Logger)
l.Info("looking up user", "user_id", id)
// => {"level":"INFO","msg":"looking up user","request_id":"...","user_id":"..."}
slog also has InfoContext(ctx, ...) variants, and a handler can be written to pull values straight out of the context — useful if you're already carrying a trace ID there and don't want it in the logger. For most of my services the explicit With is clearer.
Two more that paid off
A text handler for local dev. JSON is miserable to read by eye. I branch on an env var at startup and use slog.NewTextHandler locally, which prints key=value pairs. Same call sites, friendlier output while developing.
Groups for namespacing. slog.Group("http", ...) nests related attributes under one key, so your JSON gets a "http": {"method": ..., "status": ...} object instead of a flat sprawl. Worth it once a log line has more than a handful of fields.
The thing I appreciate most isn't any single feature — it's that there's now one logging vocabulary in the standard library that libraries can target too. Fewer adapters, fewer competing Logger interfaces to bridge. For new services I don't reach for anything else anymore.