Structured Logging with slog
log/slog is Go's standard library structured logger: key-value fields, leveled output, and pluggable handlers for JSON, text, or custom sinks.
Summary
Structured logging means every line carries machine-parseable fields (user_id, trace_id, err) instead of free-form prose.
Go 1.21 added log/slog to replace ad hoc log.Printf and reduce dependency on third-party loggers for most services.
Handlers control output format and filtering; Logger methods attach contextual attributes that flow to child loggers.
Migration from log and from zap often starts by wrapping handlers and matching field names your log platform already indexes.
Recipe
Quick-reference recipe card - copy-paste ready.
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
AddSource: true,
}))
slog.SetDefault(logger)
slog.Info("payment captured", "order_id", orderID, "amount_cents", amount)When to reach for this:
- New Go services that want stdlib-only dependencies
- JSON logs shipped to Loki, Elasticsearch, or Cloud Logging
- Middleware that logs HTTP requests with consistent attribute names
- Libraries that accept
*slog.Loggerfor injectable logging - Gradual migration off
log.Printfwithout rewriting every call site at once
Working Example
package main
import (
"context"
"log/slog"
"net/http"
"os"
"time"
)
type ctxKey string
const loggerKey ctxKey = "logger"
func main() {
base := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
slog.SetDefault(base)
mux := http.NewServeMux()
mux.Handle("GET /orders/{id}", injectLogger(base.With("service", "orders-api"))(
accessLog(http.HandlerFunc(getOrder)),
))
srv := &http.Server{Addr: ":8080", Handler: mux}
slog.Info("listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil {
slog.Error("server stopped", "err", err)
os.Exit(1)
}
}
func injectLogger(l *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqLog := l.With("request_id", r.Header.Get("X-Request-ID"))
ctx := context.WithValue(r.Context(), loggerKey, reqLog)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func loggerFrom(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
return l
}
return slog.Default()
}
func accessLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
loggerFrom(r.Context()).Info("request complete",
"method", r.Method,
"path", r.URL.Path,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
func getOrder(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
loggerFrom(r.Context()).Info("fetch order", "order_id", id)
w.Write([]byte(`{"id":"` + id + `"}`))
}What this demonstrates:
- JSON handler with explicit level and service-wide default logger
- Child loggers via
Withfor per-requestrequest_idwithout mutating the parent - Context-carried
*slog.Loggerfor handlers deep in the call stack - Access logging separated from business logging with shared correlation fields
Deep Dive
How It Works
slog.Loggerholds aHandlerand optional persistent attributes fromWith/WithGroup.- Each
Info,Warn,Errorcall builds aslog.Record(time, level, message, attrs) and passes it to the handler. - Handlers implement
Enabled,Handle, andWithAttrs/WithGroupfor immutability when deriving child loggers. slog.SetDefaultwires the process-wide logger used by top-levelslog.Infocalls.
Handlers at a Glance
| Handler | Output | Typical use |
|---|---|---|
TextHandler | key=value text | Local dev, human tailing |
JSONHandler | JSON lines | Production log aggregators |
Custom Handler | Your sink | Fan-out, redaction, vendor SDK |
Levels
| Level | Value | When |
|---|---|---|
Debug | -4 | Verbose troubleshooting, dev only |
Info | 0 | Normal operations |
Warn | 4 | Recoverable anomalies |
Error | 8 | Failures requiring attention |
Set HandlerOptions.Level to filter below-threshold records before Handle runs.
Go Notes
// Lazy expensive fields - evaluated only if level enabled
slog.Info("snapshot", "payload", slog.LogValuerFunc(func() slog.Value {
return slog.StringValue(expensiveSerialize())
}))
// Groups nest attributes in JSON: {"request":{"method":"GET"}}
log := logger.WithGroup("request")
log.Info("in", "method", r.Method, "path", r.URL.Path)Migrating from log and zap
| From | slog equivalent |
|---|---|
log.Printf | slog.Info with attributes |
log.Fatal | slog.Error + os.Exit(1) |
| zap fields | slog key-value pairs or LogValuer |
| zap production config | JSONHandler + Level: Info |
Gotchas
- Logging before
SetDefault- Earlymainlogs use the text default. Configure the handler before other packages log. - Duplicate attribute keys - Later keys with the same name can confuse JSON parsers; use consistent naming conventions.
- Logging secrets - Never log auth headers, passwords, or full PANs. Redact in a custom
Handlewrapper. Errorattribute name - Use"err", errconsistently; some platforms maperrorto special columns.- High-cardinality fields - Logging raw user IDs on every debug line increases storage cost; sample or aggregate in metrics instead.
- Missing
AddSourcein prod - Source lines help debugging but add allocation; enable selectively. - Global default in tests - Tests that parse logs should inject a
slog.New(slog.NewTextHandler(&buf, nil))handler.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
log/slog | Stdlib preference, moderate QPS | You need zap's zero-allocation hot path at extreme scale |
| uber-go/zap | Proven ecosystem, very fast JSON | You want zero third-party logging deps |
| logrus | Legacy codebases already on it | Greenfield services (maintenance mode project) |
| Platform agent only | Serverless with automatic capture | You need custom correlation across services |
FAQs
Is slog stable for production?
Yes.
It has been in the standard library since Go 1.21 and is the recommended default for new Go code.
How do I log errors with stack traces?
slog does not attach stacks automatically.
Log err and wrap with fmt.Errorf("…: %w", err); use AddSource: true or a custom handler for file/line.
Can I use slog with gin or chi?
Yes.
Create middleware that logs from *http.Request context or a package-level default after SetDefault.
How do I test log output?
Write to bytes.Buffer via slog.NewTextHandler(&buf, nil) and assert substring contents in tests.
What is WithGroup for?
It namespaces attributes under a JSON key, useful for nested structures like http or db sub-objects.
Should I pass *slog.Logger or use the default?
Libraries should accept *slog.Logger parameters.
Applications can call slog.SetDefault once in main.
How do I change level at runtime?
Wrap the handler with a type that holds atomic level pointer, or use a library like slog.LevelVar pattern in Go 1.22+ examples.
Does slog support log rotation?
No.
Rotation is the job of the process manager, container sidecar, or platform (systemd, Kubernetes log driver).
How do I pretty-print JSON logs locally?
Use TextHandler in development and JSONHandler in production, selected by APP_ENV.
Can I bridge slog to zap?
Community handlers exist; prefer one logger in new code to avoid double emission and field mismatch.
What about standard log flags?
The old log package remains for minimal scripts.
slog supersedes it for services with fields and levels.
How do I log HTTP request bodies?
Avoid by default.
If required for debugging, cap size, redact fields, and restrict to sampled debug requests.
Related
- Observability Basics - slog and health quick starts
- Production Go: Logs, Metrics, Traces, and Signals - three pillars overview
- OpenTelemetry Tracing in Go Services - trace IDs in log fields
- Observability Best Practices - cardinality and on-call rules
- Middleware Chains & Request Context - HTTP middleware patterns
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).