Structured Logging with slog
Prerequisites: Testing with go test
The log/slog package (Go 1.21+) writes structured logs: each entry has a
level, a message, and key-value attributes. Structured logs are easy for
log tools to search and filter — status=500 instead of a sentence you have
to parse.
- A
slog.Loggeris what you call:logger.Info("msg", "key", value). - A handler decides the output format. The standard library ships
slog.NewTextHandler(key=valuepairs) andslog.NewJSONHandler. - Levels:
Debug,Info,Warn,Error. The handler'sLeveloption drops anything below it (the default isInfo). logger.With(...)returns a logger that adds the same attributes to every entry — handy for a request ID.
Syntax
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("user signed in", "user_id", 42, "method", "password")
logger.Error("payment failed", slog.String("order", "A-17"), slog.Int("attempt", 3))
reqLog := logger.With("request_id", "abc123")
slog.SetDefault(logger) // package-level slog.Info etc. now use this logger
Example
Real logs include a time attribute. This example removes it with
ReplaceAttr so the output is repeatable.
package main
import (
"log/slog"
"os"
"time"
)
func main() {
opts := &slog.HandlerOptions{
Level: slog.LevelDebug,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey && len(groups) == 0 {
return slog.Attr{} // an empty Attr is dropped
}
return a
},
}
text := slog.New(slog.NewTextHandler(os.Stdout, opts))
text.Info("server started", "port", 8080, "env", "dev")
text.Debug("cache warmed", "entries", 42)
jsonLog := slog.New(slog.NewJSONHandler(os.Stdout, opts))
reqLog := jsonLog.With("request_id", "abc123")
reqLog.Warn("slow request",
slog.Duration("elapsed", 1500*time.Millisecond),
slog.Int("status", 200))
reqLog.Error("payment failed",
slog.Group("user", slog.Int("id", 7), slog.String("plan", "pro")))
// A handler at Warn level drops Info and Debug entries.
quiet := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelWarn,
ReplaceAttr: opts.ReplaceAttr,
}))
quiet.Info("you will not see this")
quiet.Warn("disk almost full", "free_gb", 2)
}
Expected output
level=INFO msg="server started" port=8080 env=dev
level=DEBUG msg="cache warmed" entries=42
{"level":"WARN","msg":"slow request","request_id":"abc123","elapsed":1500000000,"status":200}
{"level":"ERROR","msg":"payment failed","request_id":"abc123","user":{"id":7,"plan":"pro"}}
level=WARN msg="disk almost full" free_gb=2
The JSON handler writes durations as nanoseconds; the text handler would
print elapsed=1.5s.
Common mistakes
- Passing an odd number of arguments (
logger.Info("msg", "user_id")). The unmatched value is logged under the key!BADKEY.go vetcatches this, and the typed helpers (slog.Int,slog.String, ...) avoid it. - Building messages with
fmt.Sprintf("user %d failed", id). Keep the message constant and put the variable parts in attributes, so logs can be filtered by them. - Logging secrets such as passwords, tokens or full card numbers.
- Leaving the level at
Debugin production and flooding your log storage. Make the level configurable (for example with aslog.LevelVar).
Practice
Create a JSON logger that only logs Warn and above and omits the time
attribute. Log an Info entry (which should not appear) and a Warn entry
"retrying" with attributes attempt=2 and service="billing".
Show solution
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelWarn,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey && len(groups) == 0 {
return slog.Attr{}
}
return a
},
}))
logger.Info("starting up") // below Warn: dropped
logger.Warn("retrying", "attempt", 2, "service", "billing")
// {"level":"WARN","msg":"retrying","attempt":2,"service":"billing"}
}