Go slog Logging
"We divided the API into a frontend,
Logger, that calls a backend interface,Handler. That way, existing logging packages can talk to a common backend, so the packages that use them can interoperate without having to be rewritten." — Structured Logging with slog
log/slog (Go 1.21) is the standard library's structured logger: every line is a message plus typed key-value attributes a machine can parse, not a sentence a human has to grep. The reader of a log is the on-call engineer paged at 3am — a structured record with the right attrs is debuggable under load; a bare log.Println(err) is not. Prefer slog over fmt.Println/log.Printf debugging and over hand-concatenated string logs. This skill owns the log side of go-error-handling's "handle each error once."
1. slog Over fmt.Println and log.Printf
fmt.Println(err) and log.Printf("got %v", x) produce a line of prose: no level, no timestamp structure, no queryable fields. slog produces a record that a log pipeline can filter, index, and alert on.
// WRONG — unstructured; "user 42 failed: timeout" can't be filtered by user or cause
log.Printf("user %d request failed: %v", id, err)
fmt.Println("got here", x) // debug print left in
// RIGHT — message + typed attrs; queryable by user, status, and error
slog.Error("request failed", slog.Int("user", id), slog.Any("err", err))
A TextHandler "writes Records to an io.Writer as a sequence of key=value pairs"; a JSONHandler "writes Records ... as line-delimited JSON objects" (pkg.go.dev/log/slog). The same call serves a human in dev and an ingestion pipeline in prod — you only swap the handler.
2. Typed Attrs Over the Key-Value Variadic — the !BADKEY Footgun
slog.Info("msg", "key", value, ...) takes alternating keys and values. The convenience is real, but the form is a footgun: drop one argument and slog does not error. "If an argument is a string and this is not the last argument, the following argument is treated as the value ... Otherwise, the argument is treated as a value with key !BADKEY" (pkg.go.dev/log/slog). An odd argument count silently mislabels your data.
// WRONG — odd args: "count" has no value, so it becomes a VALUE under "!BADKEY".
// Verified output: {"level":"INFO","msg":"oops","user":"alice","!BADKEY":"count"}
slog.Info("oops", "user", "alice", "count")
// RIGHT — typed attrs: one constructor per pair, impossible to misalign
slog.Info("ok", slog.String("user", "alice"), slog.Int("count", 3))
The designers "felt strongly that this was a bad idea ... easy to get wrong by omitting a key or value" but kept the light syntax, so they "added a vet check to catch common mistakes, but did not change the design" (Go blog: slog). go vet flags a literal odd-args call — keep go vet in CI (owned by go-tooling-and-static-analysis). Typed constructors (slog.String, slog.Int, slog.Bool, slog.Time, slog.Any) sidestep the trap entirely and self-document each field's type.
3. LogAttrs for Hot Paths
For a frequently executed log statement, the typed form has an even faster sibling. LogAttrs "is similar to Logger.Log but accepts only Attrs, not alternating keys and values; this allows it, too, to avoid allocation" (pkg.go.dev/log/slog). It takes a context.Context and an explicit level:
// RIGHT — allocation-free hot path; same output as the typed Info call
slog.LogAttrs(ctx, slog.LevelInfo, "scan complete",
slog.String("table", name), slog.Int("rows", n))
The blog found "the greatest gains came from paying careful attention to memory allocation" — LogAttrs is where that pays off. Don't reach for it everywhere; use it on the genuinely hot statements where a benchmark shows allocation matters.
4. Set a Level — and Filter Cheaply
slog has four named levels — Debug, Info, Warn, Error — and "levels are just integers, so you aren't limited to the four named levels" (Go blog: slog). The handler's minimum level drops everything below it: "Level reports the minimum record level that will be logged. The handler discards records with lower levels. If Level is nil, the handler assumes LevelInfo" (pkg.go.dev/log/slog). So by default Debug is invisible — that is correct; turn it up deliberately.
// RIGHT — minimum level set on the handler; Debug records are dropped
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})
// RIGHT — LevelVar to flip verbosity at runtime without rebuilding the handler
var lvl slog.LevelVar // defaults to Info
h = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: &lvl})
lvl.Set(slog.LevelDebug) // now Debug appears
A dropped record is cheap: the handler's Enabled check runs "at the beginning of every log event, giving the handler a chance to drop unwanted log events quickly" (Go blog: slog). HandlerOptions also offers AddSource (adds the call site) and ReplaceAttr (rewrite/redact each attr before output).
5. Pick a Handler and Set the Default at the Edges
Configure logging once, at the program edge (main/startup): build a handler, wrap it in a *slog.Logger, and install it with slog.SetDefault. "SetDefault makes l the default Logger, which is used by the top-level functions Info, Debug and so on" (pkg.go.dev/log/slog). After that the rest of the program calls slog.Info/slog.Error or is handed a *slog.Logger — it never reconfigures global state.
func main() {
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})
slog.SetDefault(slog.New(h)) // JSON for prod ingestion; TextHandler for dev
// ... run the app; deeper code just calls slog.Info(...) ...
}
TextHandler (key=value, readable) for local development; JSONHandler (line-delimited JSON) for production ingestion. Choosing the handler is a one-line edge decision — don't sprinkle handler construction through the codebase (the global-config-everywhere anti-pattern; go-idiomatic-discipline bans mutable global reconfiguration).
6. With for Persistent Context, Group for Nesting
When several log calls share an attribute (a request ID, a user), don't repeat it. With "returns a Logger that includes the given attributes in each output operation" (pkg.go.dev/log/slog) — and pre-formats them once, so it is faster as well as terser.
// RIGHT — requestID rides every record from reqLog, set once
reqLog := slog.Default().With(slog.String("requestID", id))
reqLog.Info("received") // ...,"requestID":"abc-123"
reqLog.Info("validated") // ...,"requestID":"abc-123"
// RIGHT — Group nests related fields under one key
slog.Info("connected", slog.Group("db",
slog.String("host", host), slog.Int("port", port)))
// ...,"db":{"host":"db1","port":5432}
7. Carry the Context — InfoContext
Info/Warn/Error have ...Context variants (InfoContext, ErrorContext, and the general Log/LogAttrs) that take a context.Context first: "InfoContext logs at LevelInfo with the given context" (pkg.go.dev/log/slog). Pass ctx so a handler can extract request-scoped values — trace IDs, deadlines — and so cancellation is observable.
// RIGHT — ctx flows to the handler, which can attach a trace ID from it
slog.InfoContext(ctx, "handling", slog.String("route", r.URL.Path))
go-context owns context propagation and trace plumbing; the rule here is simply: in request-scoped code, prefer the ...Context form.
8. Don't Log-and-Return the Same Error
The highest-frequency logging mistake is double handling: logging an error and returning it, so every layer logs the same failure and the log fills with duplicates of one incident. An error is handled once — match it, recover from it, or return it (wrapped), but not "log it and return it."
// WRONG — logged here AND returned; every caller logs it again
if err != nil {
slog.Error("get user failed", slog.Any("err", err))
return err
}
// RIGHT — wrap and return; the top-level handler logs it once
if err != nil {
return fmt.Errorf("get user %q: %w", id, err)
}
Logging and degrading gracefully (not returning) is fine — that is handling it once. The wrap-and-return mechanics are owned by go-error-handling; this skill owns the log side of its "handle once."
9. Never Log Secrets, Tokens, or PII
A log is a durable, widely-replicated, often-third-party-ingested artifact. Anything you log may sit in a SIEM, a vendor dashboard, and a backup for years. Passwords, API keys, session tokens, full card numbers, and personal data must never reach an attr.
// WRONG — token and password now live in every log sink forever
slog.Info("login", slog.String("token", tok), slog.String("password", pw))
// RIGHT — log an identifier and an outcome, never the credential
slog.Info("login ok", slog.String("user", user), slog.Bool("mfa", true))
Redact centrally with HandlerOptions.ReplaceAttr, or implement slog.LogValuer on a secret type so it renders as REDACTED. The default is: log the fact, not the secret.
10. Don't log.Fatal / panic for Ordinary Errors in a Library
log.Fatal "is equivalent to Print followed by a call to os.Exit(1)" (pkg.go.dev/log) — and os.Exit runs no deferred functions, so every defer (flushes, unlocks, cleanup) is skipped and the process dies mid-flight. A library has no business killing its caller's process over a failed lookup. log.Panic "is equivalent to Print followed by a call to panic()."
// WRONG — in a library: kills the caller's process, skips all defers
func Load(path string) Config {
data, err := os.ReadFile(path)
if err != nil {
log.Fatal(err) // os.Exit(1): no defer runs, caller can't recover
}
// ...
}
// RIGHT — return the error; let the caller (or main) decide to exit
func Load(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("read config %s: %w", path, err)
}
// ...
}
log.Fatal is acceptable only at the very top of main, where exiting is the intended outcome. go-defer-panic-recover owns why os.Exit skips defers; go-error-handling owns returning errors as values.
11. Don't Over-Log in Hot Loops
A log inside a tight loop can dominate runtime and bury the signal. Log state transitions and failures, not every iteration; gate detail behind Debug (dropped cheaply at Info) or aggregate into one summary.
// WRONG — one record per row; millions of lines, the real event is invisible
for _, row := range rows {
slog.Info("processing row", slog.Int("id", row.ID))
}
// RIGHT — Debug (filtered out in prod) for the detail; Info for the summary
for _, row := range rows {
slog.Debug("processing row", slog.Int("id", row.ID))
}
slog.Info("batch done", slog.Int("rows", len(rows)))
12. Routing to Related Skills
go-idiomatic-discipline— the policy root; "errors are values, never silently discarded" and no mutable global reconfiguration.go-error-handling— wrap-and-return; the don't log-and-return rule of §8 is the log side of its "handle once."go-context—InfoContextand propagating trace IDs / cancellation throughctx(§7).go-defer-panic-recover— whylog.Fatal'sos.Exitskips everydefer(§10), and whenpanicis legitimate.go-version-feature-map—log/slogarrived in Go 1.21; check the module'sgodirective before using it.go-testing-tabledriven— capturing handler output into abytes.Bufferto assert on log records in tests.go-tooling-and-static-analysis— thesloggo vetcheck that catches the literal odd-args!BADKEYbug (§2).
13. Reference Files
High-frequency slog anti-patterns in LLM-generated Go, each with wrong/right code and citations:
references/common-mistakes.md
Source provenance for every claim in this skill:
references/sources.yaml