🏷️ Analogy
fmt.Println("user 42 failed") is a handwritten sticky note — fine for one person, useless to a machine that must search a million of them. log/slog is a filing system: every log line carries labeled fields (user=42, status=503), so a log pipeline can filter, count, and alert on them. Same effort to write; vastly more useful when something breaks at 3am.
Logging with attributes
log/slog (Go 1.21+) logs a message plus key-value attributes. The package-level slog.Info/Warn/Error work out of the box, but in real code you build a logger with an explicit handler that decides the format and destination. A TextHandler writes readable key=value lines:
package main
import (
"log/slog"
"os"
)
func main() {
// Drop the time attribute so the output is deterministic here.
// In production you keep the real timestamp.
opts := &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
}
logger := slog.New(slog.NewTextHandler(os.Stdout, opts))
logger.Info("server started", "port", 8080, "env", "prod")
logger.Warn("cache miss", "key", "user:42")
// slog.With attaches common fields to every later line.
reqLog := logger.With("request_id", "abc-123")
reqLog.Info("handling request", "method", "GET", "path", "/health")
reqLog.Error("upstream failed", "status", 503)
}
Notice reqLog carries request_id=abc-123 on both of its lines without repeating it — that’s slog.With building a child logger with shared context.
Levels and the threshold
slog has four built-in levels — Debug, Info, Warn, Error — and the handler filters by a configurable threshold, so you can silence Debug in production without touching call sites:
graph LR
CALL["logger.Info/Warn/Error<br/>(msg + attrs)"] --> EN{"level ≥ threshold?"}
EN -->|no| DROP["discarded (cheap)"]
EN -->|yes| H{"Handler"}
H -->|"TextHandler"| TXT["level=INFO msg=... key=val"]
H -->|"JSONHandler"| JSON["{level:INFO, msg:..., key:val}"]
TXT --> OUT["os.Stdout / file"]
JSON --> OUTThe level is set on the handler via HandlerOptions{Level: ...}. Wire it to a --verbose flag and you can flip Debug on for one run. Because slog checks Enabled() before formatting, a dropped Debug line costs almost nothing.
Text vs JSON handlers
Swapping the handler changes the format without touching any log call. For a machine-readable pipeline, use JSONHandler — every line becomes one JSON object:
package main
import (
"log/slog"
"os"
)
func main() {
opts := &slog.HandlerOptions{
Level: slog.LevelInfo, // Debug lines are dropped
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{} // strip time for a deterministic demo
}
return a
},
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
logger.Debug("verbose detail", "n", 1) // below threshold → not printed
logger.Info("server started", "port", 8080)
logger.Error("db down", "attempt", 3)
// {"level":"INFO","msg":"server started","port":8080}
// {"level":"ERROR","msg":"db down","attempt":3}
}
To make a logger the default for the package-level slog.Info calls everywhere, register it once with slog.SetDefault(logger).
Grouping and typed attributes
Related fields can be nested under a group with slog.Group, which namespaces them (req.method, req.path in text; a nested object in JSON). And for hot paths, slog.LogAttrs with typed slog.Attr values skips the any-boxing of the loose form and is compiler-checked:
package main
import (
"context"
"log/slog"
"os"
)
func main() {
opts := &slog.HandlerOptions{
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
}
logger := slog.New(slog.NewTextHandler(os.Stdout, opts))
// Group related attributes under one key.
logger.Info("request done",
slog.Group("req", slog.String("method", "GET"), slog.Int("status", 200)),
)
// LogAttrs: typed, allocation-light, compiler-checked.
logger.LogAttrs(context.Background(), slog.LevelInfo, "metrics",
slog.Int("rps", 1200), slog.Float64("p99_ms", 8.4),
)
}
Reference
| Want | Use |
|---|---|
| A readable local logger | slog.New(slog.NewTextHandler(os.Stdout, nil)) |
| Machine-ingestible logs | slog.New(slog.NewJSONHandler(...)) |
| Set the global default | slog.SetDefault(logger) |
| Attach context to all lines | logger.With("key", v) |
| Namespace related fields | slog.Group("name", attrs...) |
| Typed, fast attributes | slog.LogAttrs(ctx, level, msg, slog.Int(...)) |
| Set the minimum level | &slog.HandlerOptions{Level: slog.LevelDebug} |
| Add source file/line | &slog.HandlerOptions{AddSource: true} |
Under the hood
A *slog.Logger is a thin front end; the work happens in a slog.Handler — an interface with Enabled, Handle(Record), WithAttrs, and WithGroup. A call assembles a slog.Record (time, level, message, attrs) and, if Enabled passes, hands it to the handler to format and write. Because Handler is just an interface, you can write your own (route errors to Sentry, sample noisy lines, fan out to several sinks) and the rest of your code is unchanged. With/WithGroup return a logger whose handler has the attributes pre-bound, so shared context is computed once, not per line.
⚠️ Odd args silently corrupt a line
The variadic key, value, key, value form is convenient but fragile: forget a value and slog logs a !BADKEY entry instead of failing. For anything important, use the typed helpers — slog.Int("port", 8080), slog.String("env", "prod"), slog.Any("user", u) — which the compiler checks and which avoid boxing each value into an any. On hot paths prefer slog.LogAttrs. Reserve the loose "key", value form for quick, low-stakes lines.
See also
- CLI Tools with flag — a
--verboseflag to toggle the log level. - errors — log a wrapped error with
slog.Any("err", err). - files & os — point a handler at a file (
*os.Fileis anio.Writer). - testing-basics — capture log output in a
bytes.Bufferto assert on it.
Next: shelling out to other programs — os/exec.
Related topics
Build command-line tools with the flag package — typed options with defaults, binding with Var, positional args, subcommands via FlagSet, custom flag.Value types, and env-var fallbacks.
testingTestingThe testing package and go test — writing TestXxx functions, Errorf vs Fatalf, t.Helper, and the got/want convention that runs with go test ./...
Check your understanding
Score: 0 / 51. Why are structured logs (key=value) better than fmt.Println in production?
A line like level=ERROR msg="upstream failed" status=503 carries named fields. Log pipelines (and JSON handlers) can index and query those fields. fmt.Println produces opaque text you can only grep, and which breaks the moment you reword the message.
2. What does slog.With("request_id", id) give you?
slog.With returns a new *slog.Logger carrying the given attributes. Every call on that logger includes them, so you set request_id (or user, trace) once and it appears on all related lines — no repeating yourself.
3. How do you choose between TextHandler and JSONHandler?
Both write the same attributes; only the format differs. TextHandler emits key=value lines that are easy to read in a terminal. JSONHandler emits one JSON object per line — ideal for ingestion by tools like Loki, Elasticsearch, or CloudWatch.
4. A handler is set to slog.LevelInfo. What happens to logger.Debug(...) calls?
Each handler has a minimum level. Records below it are discarded — and slog checks Enabled() first, so a disabled Debug call avoids most of the formatting work. You raise/lower the threshold without touching call sites.
5. Why prefer slog.LogAttrs / typed attrs (slog.Int, slog.String) over the loose "key", value form?
The variadic key,value form can desync (a forgotten value logs !BADKEY) and boxes every value into an any. slog.LogAttrs(ctx, level, msg, slog.Int(...), ...) takes typed slog.Attr values — checked at compile time and allocation-light, which matters on hot paths.
Comments
Sign in with GitHub to join the discussion.