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.
Search across all documentation pages
log/slog is Go's standard library structured logger: key-value fields, leveled output, and pluggable handlers for JSON, text, or custom sinks.
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.
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:
*slog.Logger for injectable logginglog.Printf without rewriting every call site at oncepackage 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:
With for per-request request_id without mutating the parent*slog.Logger for handlers deep in the call stackslog.Logger holds a Handler and optional persistent attributes from With / WithGroup.Info, Warn, Error call builds a slog.Record (time, level, message, attrs) and passes it to the handler.Enabled, Handle, and WithAttrs / WithGroup for immutability when deriving child loggers.slog.SetDefault wires the process-wide logger used by top-level slog.Info calls.| 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 |
| 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.
// 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)| 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 |
SetDefault - Early main logs use the text default. Configure the handler before other packages log.Handle wrapper.Error attribute name - Use "err", err consistently; some platforms map error to special columns.AddSource in prod - Source lines help debugging but add allocation; enable selectively.slog.New(slog.NewTextHandler(&buf, nil)) handler.| 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 |
Yes.
It has been in the standard library since Go 1.21 and is the recommended default for new Go code.
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.
Yes.
Create middleware that logs from *http.Request context or a package-level default after SetDefault.
Write to bytes.Buffer via slog.NewTextHandler(&buf, nil) and assert substring contents in tests.
It namespaces attributes under a JSON key, useful for nested structures like http or db sub-objects.
Libraries should accept *slog.Logger parameters.
Applications can call slog.SetDefault once in main.
Wrap the handler with a type that holds atomic level pointer, or use a library like slog.LevelVar pattern in Go 1.22+ examples.
No.
Rotation is the job of the process manager, container sidecar, or platform (systemd, Kubernetes log driver).
Use TextHandler in development and JSONHandler in production, selected by APP_ENV.
Community handlers exist; prefer one logger in new code to avoid double emission and field mismatch.
The old log package remains for minimal scripts.
slog supersedes it for services with fields and levels.
Avoid by default.
If required for debugging, cap size, redact fields, and restrict to sampled debug requests.
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).
Reviewed by Chris St. John·Last updated Jul 18, 2026