Structured Logging with log/slog

September 10, 2024

Structured Logging with log/slog
Structured Logging with log/slog

Go 1.21 added log/slog to the standard library. Structured logging without a dependency.

Estimated Reading Time : 8m

What’s wrong with log.Println

log.Printf("user %s logged in from %s", username, ip)

This produces a human-readable string. Parsing it programmatically — in a log aggregator, alerting system, or grep pipeline — means writing regex against an unstructured format that changes every time you edit the message.

Structured logging emits key-value pairs:

time=2024-09-10T14:30:00Z level=INFO msg="user logged in" user=alice ip=10.0.0.1

Every field is named and extractable. slog brings this to the standard library.

Basic usage

package main

import "log/slog"

func main() {
    slog.Info("user logged in",
        "user", "alice",
        "ip", "10.0.0.1",
    )
}

Output:

2024/09/10 14:30:00 INFO user logged in user=alice ip=10.0.0.1

Key-value pairs are passed as alternating arguments after the message. slog.Info, slog.Debug, slog.Warn, and slog.Error are the four log levels.

Handlers

A handler controls the output format. slog ships with two:

TextHandler

Human-readable key-value format:

logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Info("request", "method", "GET", "path", "/users", "status", 200)
time=2024-09-10T14:30:00.000Z level=INFO msg=request method=GET path=/users status=200

JSONHandler

Machine-readable JSON — what most log aggregators (Datadog, Loki, CloudWatch) expect:

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("request", "method", "GET", "path", "/users", "status", 200)
{"time":"2024-09-10T14:30:00.000Z","level":"INFO","msg":"request","method":"GET","path":"/users","status":200}

Setting the default logger

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

    // Now slog.Info(), slog.Debug(), etc. use the JSON handler
    slog.Info("server starting", "port", 8080)
}

slog.SetDefault also redirects the old log package’s output through the new handler, so third-party libraries using log.Println get structured output too.

Log levels

slog.Debug("cache miss", "key", cacheKey)     // -4
slog.Info("request handled", "status", 200)    //  0
slog.Warn("rate limit approaching", "pct", 85) //  4
slog.Error("query failed", "err", err)         //  8

Set the minimum level in HandlerOptions:

slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelWarn, // only Warn and Error
})

Typed attributes

Alternating key, value arguments work but aren’t type-checked. Use slog.Attr for explicit typing:

slog.LogAttrs(ctx, slog.LevelInfo, "request",
    slog.String("method", "GET"),
    slog.Int("status", 200),
    slog.Duration("latency", elapsed),
    slog.String("path", "/users"),
)

slog.String, slog.Int, slog.Duration, slog.Bool, slog.Time, slog.Any — each creates a typed attribute. This avoids the overhead of interface boxing and catches type mismatches at compile time.

Groups

Groups namespace related attributes:

slog.Info("request",
    slog.Group("req",
        slog.String("method", "GET"),
        slog.String("path", "/users"),
    ),
    slog.Group("resp",
        slog.Int("status", 200),
        slog.Duration("latency", 12*time.Millisecond),
    ),
)

JSON output:

{"time":"...","level":"INFO","msg":"request","req":{"method":"GET","path":"/users"},"resp":{"status":200,"latency":"12ms"}}

Logger with context

Attach common fields to a logger so you don’t repeat them on every call:

logger := slog.Default().With(
    "service", "api",
    "version", "1.4.2",
)

logger.Info("started")  // includes service=api version=1.4.2 automatically
logger.Info("request", "path", "/users")  // also includes service and version

With returns a new logger — the original is unchanged. This is useful for per-request loggers:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    logger := slog.Default().With(
        "request_id", r.Header.Get("X-Request-ID"),
        "method", r.Method,
        "path", r.URL.Path,
    )

    logger.Info("request started")
    // ... handle request ...
    logger.Info("request completed", "status", statusCode)
}

Passing loggers through context

ctx := context.Background()

// Store logger in context
logger := slog.Default().With("request_id", "abc-123")
ctx = context.WithValue(ctx, loggerKey, logger)

// Retrieve later
slog.InfoContext(ctx, "processing", "step", "validation")

For a cleaner API, use a helper:

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

Migrating from other loggers

If you’re using logrus or zap, slog covers the same ground with a smaller API surface:

Feature logrus zap slog
Structured fields .WithField() .With() .With()
JSON output built-in built-in JSONHandler
Log levels 7 levels 5 levels 4 levels
Performance slowest fastest fast
Dependency external external standard library

slog is faster than logrus and slightly slower than zap in benchmarks. For most applications, the difference is negligible — and having no dependency is worth a lot.

The bottom line

Use slog.NewJSONHandler in production, slog.NewTextHandler in development. Attach context with With. Use slog.SetDefault once at startup and the rest of your application just calls slog.Info().