How to configure log/slog in a real service: runtime-switchable levels, attrs and groups, context-aware handlers, source locations, and the allocation traps.
· 6 min read

Structured Logging in Go with log/slog: Levels, Attrs, Groups, and Handlers


You can use log/slog for weeks without knowing where the line between Logger and Handler sits, and then lose an afternoon to a wrapper handler that keeps getting silently unwrapped. So start with the line.

A Logger builds a Record from your arguments (time, level, message, attributes) and hands it to a Handler, which decides what to do with it. Every feature in the package lives on one side of that boundary: JSON output, minimum levels, groups, trace IDs pulled out of a context. Know which side you’re on and the API stops looking like a grab bag of loosely related functions.

This post covers the parts you end up configuring in a real service. Levels you can change while the process is running, attributes and groups, context-aware handlers, source locations, and the places where logging quietly costs you allocations. For the wider picture of logging options in Go, we have a broader guide to logging in Go.

Setting up a default logger with a changeable level

The default handler formats records as text and writes through the old log package. That can be fine for small programs, but services usually benefit from machine-readable output. Build a JSONHandler and install it with slog.SetDefault.

Use a *slog.LevelVar, not a plain slog.Level. A Level fixes the handler’s minimum level for its lifetime. A LevelVar holds a level, satisfies the Leveler interface, and is safe to read and write from multiple goroutines, so you can flip a running process to debug without a redeploy.

package main

import (
	"log/slog"
	"os"
)

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

func main() {
	h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
		Level:     programLevel,
		AddSource: true,
	})
	slog.SetDefault(slog.New(h))

	slog.Info("service starting", "port", 8080)

	// Later, e.g. from a debug HTTP endpoint or a SIGUSR1 handler:
	programLevel.Set(slog.LevelDebug)
	slog.Debug("verbose logging enabled")
}

SetDefault has two side effects worth knowing. It redirects the log package’s default logger through your handler, so third-party code calling log.Printf lands in your JSON stream without anyone rewriting it. And the level for those bridged records is controlled separately, by slog.SetLogLoggerLevel.

AddSource: true records the file and line of the log call. It isn’t free, since the source position comes from walking the call stack, so turn it on deliberately rather than by habit.

Attributes, groups, and keeping messages stable

slog.Info("hello", "count", 3) and slog.Info("hello", slog.Int("count", 3)) produce the same record. The alternating key-value form is shorter. The typed Attr form is faster and catches mistakes like an odd number of arguments.

The habit that matters most has nothing to do with either: keep the message constant and push everything variable into attributes. slog.Info("request finished", "status", 200) groups cleanly in a log backend. slog.Info(fmt.Sprintf("request finished with %d", 200)) gives you a thousand distinct messages and no way to aggregate them.

Groups qualify keys so subsystems can’t collide. TextHandler joins them with a dot; JSONHandler nests them as objects.

package main

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

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	r, _ := http.NewRequest("GET", "http://example.com/orders", nil)

	// One-shot group.
	logger.Info("finished",
		slog.Group("req",
			slog.String("method", r.Method),
			slog.String("url", r.URL.String())),
		slog.Int("status", http.StatusOK),
		slog.Duration("duration", 42*time.Millisecond))

	// Attrs-only form: no boxing of keys and values into `any`.
	logger.LogAttrs(context.Background(), slog.LevelInfo, "finished",
		slog.Int("status", http.StatusOK),
		slog.GroupAttrs("req", // Go 1.25+, the Attr-only version of Group
			slog.String("method", r.Method),
			slog.String("url", r.URL.String())))
}

Logger.WithGroup applies the qualification to a whole logger. That’s how you hand a scoped logger to a package without worrying about key clashes:

parserLogger := slog.Default().WithGroup("parser").With("id", systemID)
parseInput(input, parserLogger) // its "id" key becomes parser.id

Order matters here. WithGroup qualifies attributes added after it; attributes already attached with With stay in their original scope.

Logger.With is also the cheap way to attach request-scoped fields. The built-in handlers format those attributes once, at the With call, rather than on every record that follows.

Contexts and writing a context-aware handler

Logger.Log and Logger.LogAttrs take a context.Context first. The convenience methods don’t, but each has a Context variant: InfoContext, WarnContext, ErrorContext, DebugContext. Pass a context whenever you have one. See also why context belongs first in your function signatures.

The context has nothing to do with cancellation here. slog hands it to Handler.Enabled and Handler.Handle so handlers can read values out of it, and the canonical use is attaching a trace or span ID without threading it through every call site.

Here’s a handler that does that. Two details are easy to miss. Call Record.Clone before adding attributes, because a Record refers to attribute state indirectly and a plain copy can reach back into the original. And override WithAttrs/WithGroup, because the embedded handler’s versions return the inner handler, which quietly strips your wrapper the first time someone calls With.

package main

import (
	"context"
	"log/slog"
	"os"
)

type traceIDKey struct{}

// TraceHandler copies a trace ID from the context onto every record.
type TraceHandler struct {
	slog.Handler // delegates Enabled and the methods we don't override
}

func (h TraceHandler) Handle(ctx context.Context, r slog.Record) error {
	if id, ok := ctx.Value(traceIDKey{}).(string); ok {
		r = r.Clone() // don't mutate state shared with the caller's Record
		r.AddAttrs(slog.String("trace_id", id))
	}
	return h.Handler.Handle(ctx, r)
}

// Without these, With/WithGroup would unwrap TraceHandler.
func (h TraceHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	return TraceHandler{h.Handler.WithAttrs(attrs)}
}

func (h TraceHandler) WithGroup(name string) slog.Handler {
	return TraceHandler{h.Handler.WithGroup(name)}
}

func main() {
	base := slog.NewJSONHandler(os.Stdout, nil)
	logger := slog.New(TraceHandler{base})

	ctx := context.WithValue(context.Background(), traceIDKey{}, "abc-123")
	logger.InfoContext(ctx, "handling request", "path", "/orders")
}

The same wrapping shape covers filtering (override only Enabled), fan-out to multiple sinks, and enrichment. If you’re building something that formats output itself, read the Go team’s handler-writing guide first; the contract has more corners than it looks. Resolve values, inline groups with empty keys, skip zero Attrs, do your own locking. For context fundamentals, including why context.Value should stay rare, we have a separate post.

In tests, slog.DiscardHandler throws everything away and saves you wiring up io.Discard.

Log call arguments are always evaluated

Here’s the trap. Arguments to a log call are evaluated even when the level is disabled.

slog.Debug("frobbing", "value", computeExpensiveValue(arg)) // always runs

Two fixes exist, and they solve different problems.

When the work only needs to happen if the record is printed, implement LogValuer. The handler calls LogValue lazily:

type expensive struct{ arg int }

func (e expensive) LogValue() slog.Value {
	return slog.AnyValue(computeExpensiveValue(e.arg))
}

// computeExpensiveValue runs only when Debug is enabled.
slog.Debug("frobbing", "value", expensive{arg})

LogValuer is also useful for redaction. A Password type whose LogValue returns slog.StringValue("REDACTED") is redacted when that value is passed to slog. If a password is nested inside another struct, make the containing type a LogValuer too; encoding/json does not recursively apply LogValuer to fields inside arbitrary values. Return a slog.GroupValue and a type expands into several attributes instead of one opaque blob.

When the value already knows how to format itself, stop formatting it yourself:

slog.Info("starting request", "url", r.URL.String()) // String() always called
slog.Info("starting request", "url", r.URL)          // formatted only if enabled

The second version wins twice. TextHandler calls String lazily, and JSONHandler keeps the structure, emitting the parsed URL as a nested object. Pre-stringifying throws away exactly the structure you adopted structured logging to get.

The rest of the performance advice is short. Hoist repeated attributes into Logger.With. Use LogAttrs on hot paths, since Value holds numbers and strings without allocating and the Attr-only signature skips the any boxing. Reach for any of this only after a profile puts logging on your critical path.

Source locations when you wrap slog

Write a helper like this and slog reports your helper’s file as the source, because it walks the stack looking for the log site:

func Infof(logger *slog.Logger, format string, args ...any) {
	logger.Info(fmt.Sprintf(format, args...)) // source = mylog.go, not the caller
}

Capture the program counter yourself and build the Record directly:

func Infof(logger *slog.Logger, format string, args ...any) {
	if !logger.Enabled(context.Background(), slog.LevelInfo) {
		return
	}
	var pcs [1]uintptr
	runtime.Callers(2, pcs[:]) // skip [Callers, Infof]
	r := slog.NewRecord(time.Now(), slog.LevelInfo, fmt.Sprintf(format, args...), pcs[0])
	_ = logger.Handler().Handle(context.Background(), r)
}

Don’t drop the Enabled check. You’ve gone around Logger, so nothing else will short-circuit that Sprintf.

The correct runtime.Callers skip depends on the wrapper’s call depth, which is a decent argument for not writing one. A printf-style facade over slog costs you a stack-depth bug waiting to happen, plus the variable messages you were trying to get away from. Pass a scoped *slog.Logger down instead and let the call sites keep their own source lines.