Structured Logging in Go: A Practical slog Field Guide

Every codebase has a logging moment of reckoning. It arrives when someone has to debug a production incident at 2 a.m. by grepping unstructured text across three services, or when a log pipeline invoice arrives and half the volume turns out to be free-form prose nobody can query. At that point, logging stops being a debugging convenience and becomes a data problem — and data problems need structure, not prose.

Go answered this in version 1.21 with the log/slog package: structured, leveled logging in the standard library, with no third-party dependency. It grew out of one of the longest-running discussions in the Go community, and it landed with a design that supports both simple cases — a one-liner in a small tool — and serious cases: JSON pipelines, attribute inheritance, custom value redaction, and drop-in adapters for existing handlers. This post walks through the parts of slog that matter in production, with code you can copy into a real service.

The Core Model: Logger, Attr, Handler

Slog is built from three small concepts. A Logger is what you call methods on. An Attr is a key–value pair attached to a log call. A Handler decides how records actually get written — as JSON, as text, or through a bridge to some other sink.

The split matters because it separates what you log from how it is rendered. Application code attaches attributes; a single configuration point decides the output format and destination. You can switch a service from human-readable text in development to JSON in production without touching a single log call.

There is also a package-level convenience layer: functions like slog.Info and slog.Error log through a default logger, so a five-line script needs zero setup:

package main

import (
    "log/slog"
    "net/http"
)

func main() {
    resp, err := http.Get("https://example.com/health")
    if err != nil {
        slog.Error("health check failed", slog.Any("err", err))
        return
    }
    defer resp.Body.Close()

    slog.Info("health check ok",
        slog.String("status", resp.Status),
        slog.Int("code", resp.StatusCode),
    )
}

The attributes make the difference visible immediately. Instead of one sentence you get a machine-readable record: status is filterable, code is chartable, and a log pipeline can route on any key without regex archaeology.

Set Up the Default Logger Once

The package-level functions are only as good as the default logger behind them. In anything that ships to production, configure that logger at startup — usually with a JSON handler — and call slog.SetDefault:

package main

import (
    "log/slog"
    "os"
)

func main() {
    level := slog.LevelInfo
    if os.Getenv("VERBOSE") != "" {
        level = slog.LevelDebug
    }

    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: level,
    })

    slog.SetDefault(slog.New(handler))
    slog.Info("service starting", slog.String("env", os.Getenv("ENV")))
}

Everywhere else in the program, the top-level slog.Info-style calls — or slog.Default() when a function needs to accept a logger explicitly — now emit structured JSON. The design principle is the same one that shaped the log/slog proposal itself: the fast path should be trivial, and the structured path should be the default, not an opt-in.

One detail worth knowing: SetDefault also reroutes output from the legacy log package through the slog handler, at Info level. Libraries and legacy code paths that still call log.Printf suddenly appear in your structured stream instead of breaking out into a separate format. If you want to keep that bridge quieter, slog.SetLogLoggerLevel (added in Go 1.22) raises the minimum level it emits at.

Context That Sticks: With and WithGroup

The real leverage of structured logging shows up when request-scoped context is attached once and reused everywhere. A Logger is immutable: With returns a new Logger with the given attributes permanently attached, and WithGroup nests all future attributes under a group name.

This fits HTTP middleware perfectly. Build a request logger once per request, stash it in the context, and every handler downstream inherits the request ID, route, and duration without repeating them:

package middleware

import (
    "context"
    "log/slog"
    "net/http"
    "time"
)

type ctxKey int

const loggerKey ctxKey = 1

// FromContext returns the request logger placed by Middleware.
func FromContext(ctx context.Context) *slog.Logger {
    if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
        return l
    }
    return slog.Default()
}

func Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        reqLogger := slog.Default().With(
            slog.String("request_id", r.Header.Get("X-Request-Id")),
            slog.String("method", r.Method),
            slog.String("path", r.URL.Path),
        )

        next.ServeHTTP(w, r.WithContext(
            context.WithValue(r.Context(), loggerKey, reqLogger),
        ))

        reqLogger.Info("request done",
            slog.Duration("duration", time.Since(start)),
        )
    })
}

A handler then logs through the request logger:

func handleOrder(w http.ResponseWriter, r *http.Request) {
    logger := middleware.FromContext(r.Context())
    logger.Info("order received", slog.String("sku", "widget-42"))
}

Every line this request produces now carries the same request_id, method, and path. When several services log with the same convention, a trace ID in the attributes joins those lines into a cross-service story.

WithGroup solves a quieter problem: namespace collisions. When a logger always runs under WithGroup("http"), its attributes serialize as http.request_id, http.duration, and so on — which keeps them from colliding with database or queue attributes on the same record:

httpLogger := slog.Default().WithGroup("http")
httpLogger.Info("request done", slog.Duration("duration", 42*time.Millisecond))
// JSON output: {"http":{"duration":42000000}, "msg":"request done", ...}

Redaction With LogValuer

Sooner or later someone logs a struct containing a token, and the credentials land in a log aggregator with broad read access. Slog has a built-in defense: the LogValuer interface. Any type that implements it controls how it renders in log output, and the conversion is lazy — it happens when the record is written, not when the call is made.

type AccessToken string

// LogValue redacts the token before it reaches any handler.
func (t AccessToken) LogValue() slog.Value {
    if t == "" {
        return slog.String("token", "empty")
    }
    return slog.String("token", "[REDACTED]")
}

func main() {
    tok := AccessToken("sup-secret-value")
    slog.Info("login", slog.Any("credential", tok))
    // Output attribute: credential=[REDACTED]
}

Because the redaction lives on the type, it holds no matter which call site logs the value. That is the property you want: protecting secrets should not depend on every developer remembering a rule. The same interface is handy for expensive values you only want formatted when the record is actually emitted — a LogValuer is evaluated lazily, so debug-level detail costs nothing when debug logging is off.

Filtering Levels Without Redeploying

Hard-coding a minimum level forces a recompile every time you want to hunt a bug. The Level field in HandlerOptions accepts any Leveler — an interface with a single Level() method — so the threshold can be dynamic. A small atomic-backed type reads the level from a file, a config value, or a flag on every emit:

type dynamicLevel struct {
    v atomic.Int32
}

func (d *dynamicLevel) Level() slog.Level {
    return slog.Level(d.v.Load())
}

func (d *dynamicLevel) Set(l slog.Level) {
    d.v.Store(int32(l))
}

// wire it up
var lvl dynamicLevel
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &lvl})
slog.SetDefault(slog.New(handler))

// later, e.g. from a signal handler or admin endpoint:
lvl.Set(slog.LevelDebug)

This pattern — an atomic variable behind the Leveler interface — is concurrency-safe and cheap enough to evaluate on every log call. It turns “turn on debug logging in prod” from a deploy into a one-liner.

Pitfalls Worth Avoiding

  • String formatting before logging. Calls like slog.Info(fmt.Sprintf("user %d", id)) throw away structure. Log the typed attribute: slog.Int("user_id", id). The whole point is that fields stay queryable.
  • Attribute soup. Forty keys per record is as unreadable as prose — just noisier. Decide which attributes are genuinely filtered or charted in your pipeline and keep the rest out.
  • Building loggers per call. slog.Default().With(...) inside a hot loop allocates a new Logger on every iteration. Build the With-chains once — at middleware entry, client construction, or worker startup — and pass them down.
  • Trusting the compiler to catch leaks. Go will happily log a password field. Wrap sensitive types in LogValuer early, before the first incident.

Wrapping Up

Slog gives Go a standard logging vocabulary: loggers with immutable context, typed attributes, pluggable handlers, and a bridge that sweeps legacy log output into the same stream. The package’s design discussion is worth reading if you want the reasoning behind the API, but the practical adoption path is short: set up a JSON handler at startup, thread request-scoped context through With, protect secrets with LogValuer, and make the log level dynamic. After that, your logs stop being text you search and become events you query.

Leave a Reply

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