03 - Structured Logging with slog
📋 Jump to TakeawaysGo 1.21 added log/slog to the standard library. Before that, you needed zerolog, zap, or logrus for structured logging. Not anymore.
Why Structured Logging?
Traditional logging:
log.Printf("user %s logged in from %s", username, ip)
// output: 2026/04/16 10:30:00 user alice logged in from 192.168.1.1This is a string. Good for humans, terrible for machines. Try searching your logs for all logins from a specific IP. You'd need regex.
Structured logging:
slog.Info("user logged in", "username", "alice", "ip", "192.168.1.1")
// output: time=2026-04-16T10:30:00.000Z level=INFO msg="user logged in" username=alice ip=192.168.1.1Key-value pairs. You can filter, search, and aggregate by any field. Every log aggregator (Datadog, Grafana, CloudWatch) understands this.
Basic Usage
package main
import "log/slog"
func main() {
slog.Info("server starting", "port", 8080)
slog.Debug("this won't show by default")
slog.Warn("disk space low", "available", "2GB")
slog.Error("connection failed", "host", "db.example.com", "err", "timeout")
}Four levels: Debug, Info, Warn, Error. Default level is Info, so Debug messages are hidden unless you configure otherwise.
Choosing a Handler
slog has two built-in handlers:
// Text handler — human-readable, good for development
textHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
// JSON handler — machine-readable, good for production
jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})Text output:
time=2026-04-16T10:30:00.000Z level=INFO msg="server starting" port=8080JSON output:
{"time":"2026-04-16T10:30:00.000Z","level":"INFO","msg":"server starting","port":8080}Setting Up the Default Logger
Configure once at startup, use everywhere.
func setupLogger(level string, format string) {
var logLevel slog.Level
switch level {
case "debug":
logLevel = slog.LevelDebug
case "warn":
logLevel = slog.LevelWarn
case "error":
logLevel = slog.LevelError
default:
logLevel = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: logLevel}
var handler slog.Handler
if format == "json" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
slog.SetDefault(slog.New(handler))
}Call it from main:
func main() {
cfg := LoadConfig()
setupLogger(cfg.LogLevel, "text") // "json" in production
slog.Info("server starting", "port", cfg.Port)
}Adding Source Location
Know exactly where a log line came from:
opts := &slog.HandlerOptions{
Level: slog.LevelDebug,
AddSource: true,
}Output includes file and line number:
time=... level=INFO source=main.go:42 msg="server starting" port=8080Useful for debugging. Adds a small performance cost, so enable it in development and disable in production if needed.
Logging with Context
Add fields that persist across a request, like a request ID:
func handleRequest(w http.ResponseWriter, r *http.Request) {
logger := slog.With("request_id", r.Header.Get("X-Request-ID"))
logger.Info("handling request", "method", r.Method, "path", r.URL.Path)
// ... do work ...
logger.Info("request complete", "status", 200)
}slog.With returns a new logger with the fields baked in. Every log call from logger includes request_id automatically.
Groups
Organize related fields:
slog.Info("request",
slog.Group("http",
slog.String("method", "GET"),
slog.String("path", "/api/bookmarks"),
slog.Int("status", 200),
),
slog.Group("timing",
slog.Duration("total", 45*time.Millisecond),
),
)JSON output:
{"level":"INFO","msg":"request","http":{"method":"GET","path":"/api/bookmarks","status":200},"timing":{"total":"45ms"}}Convenience Wrappers
slog doesn't have Infof or Fatal out of the box. If you need formatted messages or a function that logs and exits, write thin wrappers:
func Infof(format string, args ...any) {
slog.Info(fmt.Sprintf(format, args...))
}
func Fatal(msg string, args ...any) {
slog.Error(msg, args...)
os.Exit(1)
}
func Fatalf(format string, args ...any) {
slog.Error(fmt.Sprintf(format, args...))
os.Exit(1)
}Keep these minimal. The point of structured logging is key-value pairs, not formatted strings. Use Infof sparingly. Prefer slog.Info("msg", "key", value).
Applying to Our Project
Add a setupLogger function and wire it into startup:
func main() {
cfg := LoadConfig()
// Use JSON in production, text in development
format := "text"
if os.Getenv("ENV") == "production" {
format = "json"
}
setupLogger(cfg.LogLevel, format)
slog.Info("server starting", "port", cfg.Port)
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
slog.Info("listening", "addr", ":"+cfg.Port)
if err := http.ListenAndServe(":"+cfg.Port, mux); err != nil {
Fatal("server failed", "err", err)
}
}Key Takeaways
log/slogis in the standard library since Go 1.21. No external packages needed- Use
TextHandlerfor development,JSONHandlerfor production - Configure once at startup with
slog.SetDefault slog.Withadds persistent fields, perfect for request-scoped logging- Prefer key-value pairs over formatted strings
AddSource: trueshows file and line number. Useful for debugging