Structured Logging in Go: A Practical Guide to log/slog

When production incidents happen, the first question is always “what went wrong?” The answer lives in your logs — but only if those logs were structured to be searchable in the first place. String-matching through unstructured log lines at 3 AM is nobody’s idea of a good time.

Structured logging flips the model: instead of formatting a human-readable string at write time, you emit key-value attributes that a machine can parse, filter, and aggregate. A log line like user=42 checkout_amount=99.50 status=failed is trivially queryable. The same information buried inside "Checkout failed for user 42, amount was $99.50" is not.

Go’s log/slog package, part of the standard library since Go 1.21, provides structured logging with first-class support for both text and JSON output, context-aware logging, and a handler interface that lets you plug into any observability backend. This guide walks through the practical patterns that take you from basic usage to production-grade structured logging.

Getting Started: The Three Built-in Handlers

The slog package ships with three output strategies. The default handler writes to the standard log package in a human-friendly format. TextHandler emits key=value pairs suitable for local development. JSONHandler produces line-delimited JSON — the format you want in production.

package main

import (
    "os"
    "log/slog"
)

func main() {
    // Default logger — human-readable text via the log package
    slog.Info("server starting", "port", 8080)

    // TextHandler — key=value pairs to stderr
    textLogger := slog.New(slog.NewTextHandler(os.Stderr, nil))
    textLogger.Info("server starting", "port", 8080)

    // JSONHandler — structured JSON to stdout (production)
    jsonLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    jsonLogger.Info("server starting", "port", 8080)
}

The JSONHandler output looks like this — every record is a self-contained JSON object on its own line:

{"time":"2026-07-30T10:15:00.123456789Z","level":"INFO","msg":"server starting","port":8080}

Notice that the timestamp, level, and message are built-in fields. Your key-value pairs appear as top-level keys in the JSON object. This is the foundation: everything else builds on top of this.

Key-Value Pairs: Attrs vs. Alternating Arguments

The output methods accept attributes in two forms. The shorthand uses alternating key-value pairs, which is concise for quick logging:

slog.Info("request received", "method", "GET", "path", "/api/users", "user_id", 42)

For production code, typed Attr constructors are more explicit and slightly more efficient because they avoid reflection for common types:

slog.Info("request received",
    slog.String("method", r.Method),
    slog.String("path", r.URL.Path),
    slog.Int64("user_id", userID),
    slog.Duration("latency", time.Since(start)),
)

The slog package provides typed constructors for all common Go types: Int, Int64, Uint64, Float64, Bool, String, Duration, Time, and Any for everything else. Use them whenever the type matters for downstream parsing.

Building Loggers With Context: With and WithGroup

The real power of slog emerges when you stop repeating the same attributes on every log call. The With method returns a new logger that automatically includes the given attributes in every subsequent log line. This is how you build request-scoped loggers in an HTTP server:

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        requestID := r.Header.Get("X-Request-ID")
        if requestID == "" {
            requestID = generateID()
        }

        // Attach common attributes to every log line in this request
        logger := slog.Default().With(
            slog.String("request_id", requestID),
            slog.String("method", r.Method),
            slog.String("path", r.URL.Path),
        )

        // Store logger in context for downstream handlers
        ctx := context.WithValue(r.Context(), loggerKey{}, logger)
        r = r.WithContext(ctx)

        next.ServeHTTP(w, r)

        logger.Info("request completed",
            slog.Int("status", http.StatusOK),
            slog.Duration("latency", time.Since(start)),
        )
    })
}

Now every handler downstream of this middleware can pull the logger from context and emit log lines that automatically carry the request ID, method, and path — without repeating those attributes. The handler functions stay clean:

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

func getUserHandler(w http.ResponseWriter, r *http.Request) {
    logger := loggerFromCtx(r.Context())
    userID := r.PathValue("id")

    user, err := fetchUser(r.Context(), userID)
    if err != nil {
        logger.Error("failed to fetch user",
            slog.String("user_id", userID),
            slog.String("error", err.Error()),
        )
        http.Error(w, "not found", http.StatusNotFound)
        return
    }

    logger.Info("user fetched", slog.String("user_id", userID))
    json.NewEncoder(w).Encode(user)
}

The WithGroup method serves a different purpose: it namespaces attributes. If you have a subsystem that might use the same key names as other parts of your application, wrap it in a group to prevent collisions. The JSONHandler renders groups as nested objects, which keeps log entries clean and unambiguous.

Context-Aware Logging

The methods ending in ContextInfoContext, ErrorContext, DebugContext, and so on — accept a context.Context as their first argument. This matters for distributed tracing. A handler that integrates with OpenTelemetry can automatically extract trace and span IDs from the context and attach them to every log record, creating a unified correlation between logs and traces.

// Always pass context when available
slog.InfoContext(ctx, "database query completed",
    slog.String("query", queryName),
    slog.Duration("elapsed", elapsed),
    slog.Int("rows", rowCount),
)

The recommendation from the official docs is clear: if a context is available, pass it. The convenience methods without context are there for cases where no context exists, but in an HTTP server or background job processor, context is almost always available.

Dynamic Log Levels

In production, you typically run at Info level. When something goes wrong, you want to crank up to Debug without redeploying. The LevelVar type makes this possible. It is safe for concurrent access, so you can change it at runtime from an admin endpoint or signal handler:

var programLevel = new(slog.LevelVar) // atomic, defaults to LevelInfo

func init() {
    h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: programLevel,
    })
    slog.SetDefault(slog.New(h))
}

// Toggle debug logging from an admin endpoint or SIGHUP handler
func enableDebug(w http.ResponseWriter, r *http.Request) {
    programLevel.Set(slog.LevelDebug)
    slog.Info("debug logging enabled")
    w.WriteHeader(http.StatusOK)
}

Custom Value Types With LogValuer

The LogValuer interface lets you control how custom types appear in logs. This is invaluable for redacting sensitive data. Without it, a struct with a password field would dump the password into every log line. With it, you can ensure secrets never reach the output:

type Credentials struct {
    Username string
    Password string
    APIKey   string
}

// LogValue redacts all sensitive fields automatically
func (c Credentials) LogValue() slog.Value {
    return slog.GroupValue(
        slog.String("username", c.Username),
        slog.String("password", "[REDACTED]"),
        slog.String("api_key", "[REDACTED]"),
    )
}

// Now this is safe — secrets never leak into logs
user := Credentials{Username: "admin", Password: "s3cret", APIKey: "key_abc123"}
slog.Info("user authenticated", slog.Any("credentials", user))
// Output: ... msg="user authenticated" credentials.username=admin credentials.password=[REDACTED] credentials.api_key=[REDACTED]

A Note on Performance

Log arguments are always evaluated, even when the log level discards the record. If a log call involves an expensive computation — like serializing a large struct or calling a database — wrap it behind a LogValuer type so the work only happens when the record is actually written. The LogAttrs method is the most allocation-free path, as it accepts only typed Attr values and avoids the interface boxing that alternating key-value pairs require.

For the common case of a Logger.With call shared across many requests, the built-in handlers format the persistent attributes once at With time, not on every log call. This is why using With to attach request-scoped attributes is both cleaner and faster than passing them on every call.

Wrapping Up

Structured logging is not a nice-to-have — it is the foundation of observable systems. Without structured attributes on your log records, every incident investigation starts with grep archaeology instead of a filtered query. Go’s log/slog package, being part of the standard library, means you can adopt structured logging with zero external dependencies.

The patterns above cover the core workflow: pick JSONHandler for production, use With to build request-scoped loggers, pass context for trace correlation, implement LogValuer for sensitive types, and use LevelVar for runtime-configurable log levels. For deeper integration with distributed tracing, OpenTelemetry’s contrib packages provide a bridge that automatically injects trace context into slog records. If you need structured logging without a third-party dependency, slog covers the essentials.

Start by setting up a JSONHandler as your default logger in main(), wrap your HTTP middleware to attach request IDs, and you will have a logging setup that scales from local development to production incident response without changing a single line of application code.

Leave a Reply

Your email address will not be published. Required fields are marked *