03 - Structured Logging with slog

📋 Jump to Takeaways

Go 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.1

This 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.1

Key-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=8080

JSON 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=8080

Useful 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/slog is in the standard library since Go 1.21. No external packages needed
  • Use TextHandler for development, JSONHandler for production
  • Configure once at startup with slog.SetDefault
  • slog.With adds persistent fields, perfect for request-scoped logging
  • Prefer key-value pairs over formatted strings
  • AddSource: true shows file and line number. Useful for debugging

🚀 Ready to run?

Complete runnable examples for this lesson.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

© 2026 ByteLearn.dev. Free courses for developers. · Privacy