Skip to content
Codeloom
Go

Structured Logging in Go with slog

Learn Go's slog package for structured, leveled logging with JSON output, custom handlers, and context-aware log enrichment.

·5 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • How to use slog for structured JSON logging in production
  • Creating custom handlers and middleware for log enrichment
  • Passing loggers through context for request-scoped fields

Prerequisites

  • Basic Go knowledge
  • Understanding of HTTP handlers and middleware

Go 1.21 added log/slog to the standard library, giving Go developers a first-class structured logging package. No more choosing between log, logrus, zap, and zerolog for basic needs. This guide covers slog from basics to production patterns.

Why Structured Logging

Traditional log.Printf produces unstructured text:

2026/07/01 10:00:00 failed to fetch user u-123: connection refused

This is hard to parse, filter, and aggregate. Structured logging produces key-value pairs:

{"time":"2026-07-01T10:00:00Z","level":"ERROR","msg":"failed to fetch user","user_id":"u-123","error":"connection refused"}

Log aggregators like Datadog, Loki, and CloudWatch can index these fields and let you query them.

Getting Started with slog

Basic Usage

package main

import "log/slog"

func main() {
    slog.Info("server starting", "port", 8080, "env", "production")
    slog.Warn("cache miss", "key", "user:123")
    slog.Error("request failed", "status", 500, "path", "/api/users")
}

Default output (text format):

2026/07/01 10:00:00 INFO server starting port=8080 env=production
2026/07/01 10:00:00 WARN cache miss key=user:123
2026/07/01 10:00:00 ERROR request failed status=500 path=/api/users

JSON Handler for Production

Switch to JSON output by setting a JSONHandler as the default.

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }))
    slog.SetDefault(logger)

    slog.Info("server starting", "port", 8080)
}

Output:

{"time":"2026-07-01T10:00:00.000Z","level":"INFO","msg":"server starting","port":8080}

Log Levels

slog has four built-in levels: Debug, Info, Warn, and Error.

opts := &slog.HandlerOptions{
    Level: slog.LevelDebug, // show all levels
}

logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
logger.Debug("detailed info", "step", "parsing")
logger.Info("normal operation", "items", 42)
logger.Warn("approaching limit", "usage", 0.85)
logger.Error("operation failed", "error", err)

Dynamic Log Level

Use slog.LevelVar to change the level at runtime (e.g., via an admin endpoint).

var logLevel slog.LevelVar
logLevel.Set(slog.LevelInfo)

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: &logLevel,
}))

// Later, set to debug via an HTTP endpoint
http.HandleFunc("/admin/debug", func(w http.ResponseWriter, r *http.Request) {
    logLevel.Set(slog.LevelDebug)
    w.Write([]byte("debug logging enabled"))
})

Structured Attributes

Using slog.Attr for Type Safety

Instead of alternating key-value pairs, use typed attributes.

slog.Info("user created",
    slog.String("user_id", user.ID),
    slog.String("email", user.Email),
    slog.Int("age", user.Age),
    slog.Duration("latency", elapsed),
    slog.Time("created_at", user.CreatedAt),
)

Grouping Attributes

Group related fields under a namespace.

slog.Info("request completed",
    slog.Group("request",
        slog.String("method", r.Method),
        slog.String("path", r.URL.Path),
        slog.String("remote_addr", r.RemoteAddr),
    ),
    slog.Group("response",
        slog.Int("status", status),
        slog.Duration("duration", elapsed),
    ),
)

JSON output:

{
  "time": "2026-07-01T10:00:00Z",
  "level": "INFO",
  "msg": "request completed",
  "request": {
    "method": "GET",
    "path": "/api/users",
    "remote_addr": "192.168.1.1:5432"
  },
  "response": {
    "status": 200,
    "duration": "12.34ms"
  }
}

Logger with Preset Fields

Use logger.With() to create a child logger that includes fields on every log line.

func NewOrderService(db *sql.DB, logger *slog.Logger) *OrderService {
    return &OrderService{
        db:     db,
        logger: logger.With("service", "orders"),
    }
}

func (s *OrderService) PlaceOrder(ctx context.Context, order Order) error {
    log := s.logger.With("order_id", order.ID)

    log.Info("placing order", "total", order.Total)

    if err := s.chargePayment(ctx, order); err != nil {
        log.Error("payment failed", "error", err)
        return err
    }

    log.Info("order placed successfully")
    return nil
}

Every log line from PlaceOrder automatically includes service=orders and order_id=....

Context-Aware Logging

Storing Logger in Context

A common pattern is to enrich the logger in middleware and pass it via context.

type ctxKey string

const loggerKey ctxKey = "logger"

func LoggerFromContext(ctx context.Context) *slog.Logger {
    if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
        return l
    }
    return slog.Default()
}

func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
    return context.WithValue(ctx, loggerKey, l)
}

Logging Middleware

func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        requestID := r.Header.Get("X-Request-ID")
        if requestID == "" {
            requestID = uuid.NewString()
        }

        logger := slog.Default().With(
            "request_id", requestID,
            "method", r.Method,
            "path", r.URL.Path,
        )

        ctx := WithLogger(r.Context(), logger)

        start := time.Now()
        wrapped := &statusRecorder{ResponseWriter: w, status: 200}

        next.ServeHTTP(wrapped, r.WithContext(ctx))

        logger.Info("request completed",
            "status", wrapped.status,
            "duration", time.Since(start),
        )
    })
}

type statusRecorder struct {
    http.ResponseWriter
    status int
}

func (r *statusRecorder) WriteHeader(code int) {
    r.status = code
    r.ResponseWriter.WriteHeader(code)
}

Now handlers can retrieve the enriched logger:

func HandleGetUser(w http.ResponseWriter, r *http.Request) {
    log := LoggerFromContext(r.Context())

    log.Info("fetching user", "user_id", userID)
    // logs include request_id, method, and path automatically
}

Custom Handler

Implement slog.Handler to customize log output, add fields, or filter sensitive data.

type SensitiveFieldHandler struct {
    inner    slog.Handler
    redacted map[string]bool
}

func NewSensitiveFieldHandler(inner slog.Handler, fields ...string) *SensitiveFieldHandler {
    redacted := make(map[string]bool)
    for _, f := range fields {
        redacted[f] = true
    }
    return &SensitiveFieldHandler{inner: inner, redacted: redacted}
}

func (h *SensitiveFieldHandler) Enabled(ctx context.Context, level slog.Level) bool {
    return h.inner.Enabled(ctx, level)
}

func (h *SensitiveFieldHandler) Handle(ctx context.Context, r slog.Record) error {
    var filtered []slog.Attr
    r.Attrs(func(a slog.Attr) bool {
        if h.redacted[a.Key] {
            filtered = append(filtered, slog.String(a.Key, "[REDACTED]"))
        } else {
            filtered = append(filtered, a)
        }
        return true
    })

    newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC)
    newRecord.AddAttrs(filtered...)
    return h.inner.Handle(ctx, newRecord)
}

func (h *SensitiveFieldHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
    return &SensitiveFieldHandler{inner: h.inner.WithAttrs(attrs), redacted: h.redacted}
}

func (h *SensitiveFieldHandler) WithGroup(name string) slog.Handler {
    return &SensitiveFieldHandler{inner: h.inner.WithGroup(name), redacted: h.redacted}
}

Usage:

jsonHandler := slog.NewJSONHandler(os.Stdout, nil)
safeHandler := NewSensitiveFieldHandler(jsonHandler, "password", "token", "ssn")
logger := slog.New(safeHandler)

logger.Info("user login", "email", "alice@example.com", "password", "secret123")
// password field logged as [REDACTED]

Error Logging Pattern

Create a helper that logs errors with stack context.

func LogError(ctx context.Context, msg string, err error, attrs ...any) {
    log := LoggerFromContext(ctx)
    args := append([]any{"error", err.Error()}, attrs...)
    log.Error(msg, args...)
}

Usage:

if err := db.QueryRowContext(ctx, query).Scan(&result); err != nil {
    LogError(ctx, "database query failed", err, "query", "get_user", "user_id", id)
    return err
}

Summary

  • Use slog.NewJSONHandler for production and slog.NewTextHandler for development.
  • Set slog.SetDefault once at startup to configure the global logger.
  • Use logger.With() to create child loggers with preset fields.
  • Store enriched loggers in context via middleware for request-scoped logging.
  • Use slog.Group to namespace related attributes.
  • Use slog.LevelVar for runtime log level changes.
  • Implement slog.Handler to redact sensitive fields or customize output.
  • Structure your logs so aggregation tools can index and query them effectively.