# Go Slog Logging

> Guides Go logging with log/slog (Go 1.21+) — the stdlib STRUCTURED logger — over fmt.Println / log.Printf debugging and ad-hoc string logs. Prefer typed attrs (slog.String/Int) or LogAttrs to the key-value variadic form, which silently emits !BADKEY on an odd argument count; set a minimum level and a TextHandler/JSONHandler at the edges via SetDefault; attach context with With/Group and InfoContext; don't log-and-return the same error, don't log secrets/PII, and don't log.Fatal in a library (it skips defers). Auto-invokes when writing or editing logging, log/slog, slog.Info/Error, log.Printf/Println, or structured log attrs, and on "how should this log" / "add logging here" / replacing fmt.Println debugging. Logs are read by the on-call engineer at 3am.

- Skill: `ctoth/go-slog-logging` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-slog-logging`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-slog-logging/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-slog-logging

---


# 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](https://go.dev/blog/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.

```go
// 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](https://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](https://pkg.go.dev/log/slog)). An odd argument count silently mislabels your data.

```go
// 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](https://go.dev/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](https://pkg.go.dev/log/slog)). It takes a `context.Context` and an explicit level:

```go
// 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](https://go.dev/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](https://pkg.go.dev/log/slog)). So by default `Debug` is invisible — that is correct; turn it up deliberately.

```go
// 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](https://go.dev/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](https://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.

```go
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](https://pkg.go.dev/log/slog)) — and pre-formats them once, so it is faster as well as terser.

```go
// 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](https://pkg.go.dev/log/slog)). Pass `ctx` so a handler can extract request-scoped values — trace IDs, deadlines — and so cancellation is observable.

```go
// 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."

```go
// 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.

```go
// 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](https://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()`."

```go
// 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.

```go
// 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` — `InfoContext` and propagating trace IDs / cancellation through `ctx` (§7).
- `go-defer-panic-recover` — why `log.Fatal`'s `os.Exit` skips every `defer` (§10), and when `panic` is legitimate.
- `go-version-feature-map` — `log/slog` arrived in Go 1.21; check the module's `go` directive before using it.
- `go-testing-tabledriven` — capturing handler output into a `bytes.Buffer` to assert on log records in tests.
- `go-tooling-and-static-analysis` — the `slog` `go vet` check that catches the literal odd-args `!BADKEY` bug (§2).

---

## 13. Reference Files

High-frequency slog anti-patterns in LLM-generated Go, each with wrong/right code and citations:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

