Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Go

Tags:
  • go
  • slog
  • structured-logging
  • logging
  • context
  • observability

Go's log/slog: Structured Logging Without Reaching for a Third-Party Logger

For years the standard answer to "how do I get structured logs out of Go" was "add zap, or maybe zerolog, and wire up a couple of hundred lines of adapter code so the rest of your service doesn't have to know which one you picked." Since Go 1.21, that's no longer strictly true. log/slog is in the standard library, it does levels, key-value pairs, JSON output, and context propagation, and it's fast enough that the usual excuse for skipping it (performance) mostly evaporates.

This isn't an argument that zap and zerolog are pointless now. It's a walkthrough of what slog actually gives you, because most people who've heard of it are still writing log.Printf("user %s logged in from %s", userID, ip) and grepping their way through unstructured text at 2am.

The problem slog is solving

A formatted string like the one above is fine for a human tailing a terminal. It's miserable for a machine. If you want to filter every log line where user_id=u_123, or aggregate error rates by endpoint, you end up writing regexes against prose that was never meant to be parsed. Structured logging just means: stop building a sentence, emit key-value pairs, and let the output format (JSON, logfmt, whatever your aggregator wants) be a rendering detail rather than something baked into the call site.

package main

import (
	"log/slog"
	"os"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	logger.Info("user logged in",
		slog.String("user_id", "u_123"),
		slog.Int("attempt", 1),
	)
}

That produces a single line of JSON with time, level, msg, and your two attributes. Nothing revolutionary so far, every structured logger does this.

Handler, not Logger, is the extension point

*slog.Logger is mostly a thin, convenient wrapper. The actual work happens behind the slog.Handler interface, which has exactly four methods: Enabled, Handle, WithAttrs, and WithGroup. The standard library ships two implementations, TextHandler and JSONHandler, but because it's an interface you can wrap one to add behaviour without forking anything. A handler that drops debug logs unless a feature flag is set, or that scrubs a field before it hits stdout, is a couple of dozen lines that delegate to an embedded handler for everything else. This is the same pattern http.RoundTripper uses for middleware, and it works for the same reason: small interface, easy to decorate.

Why attributes aren't just "another interface{}"

Here's the bit that's actually interesting rather than just plumbing. A naive structured logger takes key string, value interface{} pairs, which means every int or bool you log gets boxed onto the heap so it can sit inside an interface. That allocation, multiplied across millions of log lines, is where the "logging is slow" reputation comes from.

slog.Attr avoids most of it. A slog.Value is a small struct with a kind tag and a numeric field big enough to hold a string header, an int64, a float64, a bool, or a time, plus a pointer field for anything that doesn't fit. So slog.Int("attempt", 1) or slog.String("user_id", id) never touches the heap for the common cases; the interface{} boxing only happens if you fall back to slog.Any for a type slog doesn't have a dedicated constructor for. It's a genuinely well-thought-out piece of API design for something that looks, at a glance, like it's just wrapping fmt.Sprintf.

Context-scoped loggers

The pattern you actually want in a service is a logger that already carries request-scoped fields, so every log line inside a handler has the request ID and route attached without every call site repeating them. slog doesn't do this automatically, but it composes cleanly with context.Context:

type ctxKey struct{}

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

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

func RequestLogger(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		reqID := r.Header.Get("X-Request-ID")
		if reqID == "" {
			reqID = fmt.Sprintf("%x", time.Now().UnixNano())
		}
		l := slog.Default().With(
			slog.String("request_id", reqID),
			slog.String("method", r.Method),
			slog.String("path", r.URL.Path),
		)
		next.ServeHTTP(w, r.WithContext(WithLogger(r.Context(), l)))
	})
}

Logger.With returns a new logger that carries the extra attributes on every subsequent call, so a handler further down the stack just does slog.FromContext(ctx).Warn("rate limited") and gets the request ID for free. This is the same shape as the errgroup and context-cancellation patterns Go pushes everywhere else: pass the thing through context, pull it back out where you need it.

Groups for nested structure

If your log aggregator prefers nested objects over a flat namespace, WithGroup wraps subsequent attributes under a key:

logger.WithGroup("http").Info("request handled",
	slog.String("method", "GET"),
	slog.Int("status", 200),
)
// {"time":"...","level":"INFO","msg":"request handled","http":{"method":"GET","status":200}}

This matters more than it looks, because it lets you compose loggers from different subsystems (an HTTP layer, a database layer) without their attribute names colliding, and without every call site having to prefix keys by hand.

Redacting secrets without remembering to at every call site

The recurring way logging leaks credentials isn't someone deliberately logging a password, it's someone logging a whole struct that happens to contain one, six months after the struct grew that field. slog's answer is the LogValuer interface: implement LogValue() slog.Value on the type itself, and every caller who logs it with slog.Any gets the redacted form automatically, whether they know about the sensitive field or not.

type Credentials struct {
	Username string
	Password string
}

func (c Credentials) LogValue() slog.Value {
	return slog.GroupValue(
		slog.String("username", c.Username),
		slog.String("password", "REDACTED"),
	)
}

// logger.Info("auth attempt", slog.Any("credentials", creds))
// -> {"credentials":{"username":"...","password":"REDACTED"}}

This is a much sturdier guarantee than a code review checklist. The redaction lives next to the field it protects, not in the head of whoever writes the log call.

The stuff that's easy to miss

HandlerOptions.ReplaceAttr lets you rewrite or drop any attribute before it's written, which is where you'd strip a field entirely rather than just masking it, or rename msg to message if your log pipeline expects that. AddSource: true adds the calling file and line, useful in development, usually worth turning off in production for the extra stat call it costs per log line. And if you're stuck calling into an API that wants an old-style *log.Logger, such as http.Server.ErrorLog, slog.NewLogLogger hands you a bridge that writes through your handler, so you don't need two logging configurations in the same binary.

One easy mistake: slog's variadic API takes alternating key-value arguments when you use the shorthand form (logger.Info("msg", "key", value)), and a missing value produces a log line with !BADKEY instead of a compile error. Prefer the typed constructors (slog.String, slog.Int, and so on) over the shorthand; they're not meaningfully more typing and they turn that mistake into a type error instead of a silently malformed log line.

Where zap or zerolog still earn a place in go.mod is genuinely high-throughput logging, tens of thousands of lines a second, where their more aggressive avoidance of allocations (zerolog in particular is built around not allocating at all on the hot path) shows up in a profile, or where you want built-in sampling to avoid flooding a log pipeline during an incident. For the overwhelming majority of services, though, slog gets you structured, leveled, context-aware logging with a redaction story, and it does it without adding a dependency whose API you're then stuck maintaining compatibility with across major versions.