Go's log/slog: Replacing Hand-Rolled JSON Loggers With the Standard Library
Go 1.21's log/slog replaces hand-rolled JSON loggers: a worked JSONHandler setup, runtime level changes with LevelVar, ReplaceAttr redaction, context trace IDs, and the !BADKEY and performance pitfalls to avoid.
19 Nov 2025, 18:54 UTC

If your Go service still logs with fmt.Printf or a homemade JSON wrapper around log, the useful answer is short: since Go 1.21, log/slog gives you structured, level-aware, machine-parseable logging in the standard library. You get JSON output, runtime-adjustable verbosity, and field redaction without adding a dependency — and library code that uses the default logger can share your configuration.
This guide assumes Go 1.21 or newer (check the go directive in your go.mod before importing).
The minimal setup that covers most services
Create one JSON handler writing to stdout, install it as the default, and log with typed attributes:
package main
import (
"log/slog"
"net/http"
"os"
)
func main() {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
slog.SetDefault(slog.New(handler))
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
slog.Info("request done",
slog.String("path", r.URL.Path),
slog.Int("status", 200),
)
})
http.ListenAndServe(":8080", nil)
}Run it with go run main.go, hit the endpoint with curl localhost:8080/, and confirm stdout contains one JSON object per line with time, level, msg, path, and status keys. Pipe it through jq . to verify it parses — that is the whole acceptance test for structured output.
Two details matter here. First, slog.SetDefault means any dependency that calls slog.Info on the default logger inherits your handler. Second, typed attributes (slog.String, slog.Int) skip reflection and are measurably cheaper than the alternating key-value form — more on that below.
Runtime-adjustable levels with LevelVar
Hard-coding LevelInfo means a redeploy to get debug logs during an incident. slog.LevelVar is a level you can mutate safely at runtime:
var level = new(slog.LevelVar) // defaults to Info
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
http.HandleFunc("/debug/level", func(w http.ResponseWriter, r *http.Request) {
if err := level.UnmarshalText([]byte(r.URL.Query().Get("v"))); err != nil {
http.Error(w, "bad level", http.StatusBadRequest)
return
}
})Now curl 'localhost:8080/debug/level?v=DEBUG' turns on debug logging without a restart. Verify by emitting a slog.Debug line somewhere, confirming it is absent at Info and present after the toggle. Protect that endpoint behind whatever authentication your service already has — an open verbosity knob is a denial-of-service vector on a busy service.
Redaction and renaming with ReplaceAttr
HandlerOptions.ReplaceAttr is a hook called for every attribute before encoding. The classic use is scrubbing secrets so a stray slog.Any("req", req) cannot leak an authorization header:
&slog.HandlerOptions{
Level: level,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == "authorization" || a.Key == "password" {
return slog.String(a.Key, "REDACTED")
}
return a
},
}AddSource: true in the same options struct adds a source field with file and line. It is genuinely useful while debugging, but it requires a runtime call-frame lookup per record — leave it off in latency-sensitive hot paths.
Performance: what actually costs
slog is fast enough for most services, but three costs are worth knowing:
- Alternating key-value args (
slog.Info("msg", "k", v)) go throughanyand reflection, and a missing value silently produces a!BADKEYentry in output. Prefer typed attributes. Try passing an odd number of args once in a scratch program and look at the output — recognizing!BADKEYin production logs later will save you a confusing hour. - Attribute construction happens before the level check. If building a value is expensive, guard it:
if slog.Default().Enabled(ctx, slog.LevelDebug) { ... }, or implementslog.LogValuerso the value is only formatted when the record will actually be emitted. - JSON encoding and AddSource are per-record overhead. If a single hot loop dominates, benchmark with
go test -benchbefore reaching for a third-party logger; often moving one Debug call behind anEnabledcheck is enough.
Context propagation: the part slog does not do for you
The Context-aware methods (slog.InfoContext) pass the context to the handler, but the built-in handlers ignore its values. Trace IDs do not appear automatically. The standard pattern is a small handler wrapper that pulls known keys out of the context:
type ctxHandler struct{ slog.Handler }
func (h ctxHandler) Handle(ctx context.Context, r slog.Record) error {
if id, ok := ctx.Value(traceIDKey).(string); ok {
r.AddAttrs(slog.String("trace_id", id))
}
return h.Handler.Handle(ctx, r)
}Wrap your JSON handler in this at construction, and have HTTP middleware store the trace ID in the request context. Verify end-to-end by making one request and confirming every log line for it carries the same trace_id.
Interoperating with legacy log.Logger code
Older libraries often accept a *log.Logger. slog.NewLogLogger(handler, slog.LevelInfo) adapts your handler to that interface, so their output flows through the same JSON pipeline instead of mixing plain text into your log stream. The adapted lines arrive as plain msg strings — structured, but not field-parsed.
Common mistakes to avoid
- Creating a logger but forgetting
slog.SetDefault, so dependencies log somewhere else. - Using alternating key-value pairs everywhere and shipping
!BADKEYentries; a CI check grepping test output for!BADKEYcatches this. - Logging pointers with
slog.Anyand getting pointer addresses instead of values — implementLogValueror log explicit fields. - Enabling
AddSourceglobally and paying the call-frame cost on every record in a hot service.
For a new Go service, slog with a JSON handler, a LevelVar, and a small context handler covers what most teams were previously getting from third-party loggers — with zero dependencies and a stable standard-library API.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.