Structured Logging in Go with log/slog
Plain text logs are hard to search at scale. Structured logs attach key-value fields to each message, so you can filter by user_id or status in your log platform. Go's standard library includes log/slog for this.
Getting started
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
slog.Info("server started", "port", 8080, "env", "production")
slog.Warn("slow query", "duration_ms", 1240, "table", "orders")
slog.Error("payment failed", "order_id", 42, "err", err)
}
{"time":"2026-10-11T09:30:00Z","level":"INFO","msg":"server started","port":8080,"env":"production"}
Use slog.NewTextHandler for readable output during local development.
Typed attributes and groups
slog.Info("order placed",
slog.Int("order_id", 42),
slog.Group("customer", slog.String("id", "c_9"), slog.String("plan", "pro")),
)
Child loggers carry context
func withRequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-ID")
if reqID == "" {
reqID = newID()
}
logger := slog.Default().With("request_id", reqID, "path", r.URL.Path)
ctx := context.WithValue(r.Context(), loggerKey{}, logger)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Every line logged with that logger includes the request ID, so you can follow one request through your logs.
Good logging habits
- Use consistent, lowercase snake_case keys across services.
- Log at boundaries (incoming requests, outgoing calls, errors), not every function.
- Never log secrets, tokens or full card numbers.
- Make the level configurable so you can turn on debug logs without a redeploy.
Was this article helpful?
Comments
No comments yet. Have a question or a tip? Be the first to share it.