# Go Logging Cheatsheet (log/slog)

- URL: https://karandeepsingh.ca/cheatsheets/languages/go-logging/
- Author: Karandeep Singh
- Published: 2025-09-16
- Tags: golang, logging
- Summary: Structured logging in Go with log/slog, covering handlers, levels, attribute typing, per-request context, JSON output for CloudWatch, and the odd-argument mistake that silently corrupts a line.

`log/slog` landed in Go 1.21 and settled a decade of everyone picking a different logging library. It is structured by default, it has levels, and it is in the standard library — which means a package can log without dragging a dependency into your build. This sheet is the configuration I use and the handful of traps, the sharpest being that the convenient call signature cannot be checked by the compiler. For the language itself see the [Go Cheatsheet](https://karandeepsingh.ca/cheatsheets/languages/golang/).

**tip:**
Same rule as every language: **libraries emit records, the application chooses
the handler.** A package takes a `*slog.Logger` or calls `slog.Default()`; only
`main` calls `slog.SetDefault`.

## The Two Handlers

> **Docs:** [log/slog package](https://pkg.go.dev/log/slog)

```go
import ("log/slog"; "os")

// Human-readable, for a terminal
t := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))

// One JSON object per line, for CloudWatch / Loki / anything that indexes
j := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level:     slog.LevelDebug,
    AddSource: true,          // file:line, costs a little
}))

slog.SetDefault(j)            // in main only; then slog.Info(...) anywhere
```

```text
$ go run slogdemo.go
time=2026-10-29T17:56:40.353Z level=INFO msg=deployed env=live replicas=3
{"time":"2026-10-29T17:56:40.353213471Z","level":"INFO","msg":"deployed","service":"api","version":"1.4.2","env":"live","replicas":3}
{"time":"2026-10-29T17:56:40.353219329Z","level":"WARN","msg":"retrying","service":"api","version":"1.4.2","attempt":2}
# Same record, two renderings. Choose by destination, not by taste:
# text to a terminal, JSON to anything that will index the fields.
```

## Levels

```go
slog.LevelDebug   // -4
slog.LevelInfo    //  0   the default
slog.LevelWarn    //  4
slog.LevelError   //  8
```

```go
// A level you can change at runtime, without rebuilding the handler
lvl := new(slog.LevelVar)                 // starts at Info
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})
lvl.Set(slog.LevelDebug)                  // flip it from a signal handler or an endpoint

if slog.Default().Enabled(ctx, slog.LevelDebug) {
    slog.Debug("expensive", "dump", buildDump())   // skip the work when off
}
```

Custom levels are just numbers: `const LevelTrace = slog.Level(-8)`.

## Attributes, and the One Real Trap

Two call styles. The short one is variadic `...any` alternating key and value,
which the compiler cannot check:

```text
$ go run slogdemo.go
{"time":"...","level":"INFO","msg":"odd number of args","service":"api","!BADKEY":"env"}
{"time":"...","level":"INFO","msg":"typed, no mistake possible","service":"api","env":"live"}
# The first call was slog.Info("odd number of args", "env") — a dangling
# key with no value. It compiles, it logs, and the field is garbage.
```

```diff
# deploy.go
@@ -4,5 +4,7 @@ func deploy(env string, n int) {
-    slog.Info("deployed", "env", env, "replicas", n)
-    slog.Info("deployed", "env")                 // compiles. Logs !BADKEY.
+    slog.Info("deployed",
+        slog.String("env", env),
+        slog.Int("replicas", n))                 // a missing value is now a
+                                                 // compile error, not a log line
```

```go
slog.String("env", env)      slog.Int("replicas", n)
slog.Bool("dry_run", d)      slog.Duration("took", elapsed)
slog.Time("at", t)           slog.Float64("ratio", r)
slog.Any("cfg", cfg)         // reflection; fine, but typed is better
slog.Group("http",           // nests: {"http":{"method":"GET","status":200}}
    slog.String("method", m), slog.Int("status", s))
```

**info:**
`vet` catches some of these. `go vet ./...` reports a dangling key in a
`slog` call as `call to slog.Info missing a final value`. It does not catch
every shape, which is why the typed constructors are still worth the keystrokes
on anything important.

## Context: Attributes That Follow a Request

```go
log := slog.Default().With(
    slog.String("service", "api"),
    slog.String("version", version),
)                                   // every record from here carries both

func handler(w http.ResponseWriter, r *http.Request) {
    log := log.With(slog.String("request_id", reqID(r)))
    log.InfoContext(r.Context(), "handling", slog.String("path", r.URL.Path))
}
```

`With` returns a child logger; the parent is untouched. The `…Context`
variants (`InfoContext`, `ErrorContext`) pass the `context.Context` to the
handler, which is how a custom handler can pull a trace ID out of it.

**A handler that lifts the trace ID out of the context**
```go
type traceHandler struct{ slog.Handler }

func (h traceHandler) Handle(ctx context.Context, r slog.Record) error {
    if id, ok := ctx.Value(traceKey).(string); ok {
        r.AddAttrs(slog.String("trace_id", id))
    }
    return h.Handler.Handle(ctx, r)
}

// WithAttrs and WithGroup must return the wrapper too, or .With() on the
// logger silently drops back to the embedded handler.
func (h traceHandler) WithAttrs(a []slog.Attr) slog.Handler {
    return traceHandler{h.Handler.WithAttrs(a)}
}
func (h traceHandler) WithGroup(n string) slog.Handler {
    return traceHandler{h.Handler.WithGroup(n)}
}

slog.SetDefault(slog.New(traceHandler{slog.NewJSONHandler(os.Stdout, nil)}))
```

Embedding `slog.Handler` gives you `Enabled`, `WithAttrs` and `WithGroup` for
free — but the last two return the *embedded* handler unless you override them,
which is the bug people hit about ten minutes in.

## Errors

```go
if err := deploy(ctx, env); err != nil {
    slog.ErrorContext(ctx, "deploy failed",
        slog.String("env", env),
        slog.Any("err", err))       // Any, so the error's type is preserved
    return fmt.Errorf("deploy %s: %w", env, err)
}
```

Log it **or** wrap and return it — not both at every level, or one failure
appears five times with five stack depths. The usual convention: wrap on the
way up, log once at the top where it is handled.

## Renaming and Redacting Fields

`ReplaceAttr` runs over every attribute before it is written. It is how you
match a log schema, and how you keep a secret out of the output:

```go
opts := &slog.HandlerOptions{
    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
        switch a.Key {
        case slog.TimeKey:
            a.Key = "ts"                              // match your schema
        case slog.MessageKey:
            a.Key = "msg"
        case "password", "token", "authorization":
            return slog.String(a.Key, "[REDACTED]")   // never the value
        }
        return a
    },
}
```

**danger:**
`ReplaceAttr` is a backstop, not a policy. It only catches keys you remembered
to name. The rule that actually works is to never pass a whole request, config
struct or environment map as an attribute — log the fields you need, by name.

## Testing Log Output

```go
func TestDeployLogs(t *testing.T) {
    var buf bytes.Buffer
    log := slog.New(slog.NewJSONHandler(&buf, nil))

    deployWith(log, "live")

    var got map[string]any
    if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
        t.Fatal(err)
    }
    if got["env"] != "live" {
        t.Errorf("env = %v, want live", got["env"])
    }
}
```

Structured output is testable output — one of the better arguments for
`slog` over a formatted string you would have to regex.

## Common Pitfalls

- **`slog.SetDefault` in a library or an `init()`.** It is global; the last
  package to run wins, and import order decides your logging config.
- **The old `log` package's `Fatal` and `Panic`.** `log.Fatalf` calls
  `os.Exit(1)`, so deferred functions do not run and buffers are not flushed.
  `slog` deliberately has no Fatal.
- **Logging in a hot loop.** Even a filtered-out `Debug` evaluates its
  arguments. Guard with `Enabled` when building the value costs anything.
- **Interleaved lines from goroutines.** Handlers are safe for concurrent use
  and write each record in one call, so records do not interleave — but if you
  also `fmt.Println` elsewhere, those will.
- **`AddSource: true` everywhere.** It walks the stack for every record. Worth
  it at Error, rarely worth it at Info in a hot path.

**Question:** Take your service's last error line. Could a log aggregator filter every occurrence of it by field — or would you need a regex over the message text?

## Related Cheatsheets

- [Go Cheatsheet](https://karandeepsingh.ca/cheatsheets/languages/golang/) — syntax, toolchain and stdlib
- [Go Concurrency Cheatsheet](https://karandeepsingh.ca/cheatsheets/languages/go-concurrency/) — logging from many goroutines
- [Go Security Cheatsheet](https://karandeepsingh.ca/cheatsheets/languages/go-security/) — what must never reach a log line
- [Python Logging Cheatsheet](https://karandeepsingh.ca/cheatsheets/python/python-logging/) — the same job, with handlers and dictConfig

## Quiz

1. What does `slog.Info("deployed", "env")` produce, with an odd number of key-value arguments?
   a) A compile error
   b) A line containing `"!BADKEY":"env"` — it logs, but the attribute is wrong
   c) A panic at runtime
   d) The argument is dropped silently
   Answer: b. The variadic ...any form takes alternating keys and values and cannot be checked by the compiler. A dangling argument becomes the value of a key named !BADKEY. Using slog.String("env", "live") instead makes the mistake impossible to express.

2. Why prefer `log/slog` over the old `log` package for a service?
   a) It is faster
   b) It emits structured key-value records, so a log aggregator can filter on fields instead of regex over a sentence
   c) log was removed in Go 1.21
   Answer: b. slog records carry typed attributes. With the JSON handler each line is an object that CloudWatch Logs Insights or Loki can query by field. The old log package writes a formatted string, which you then have to parse back out.

3. What is the point of `logger.With("service", "api")`?
   a) It sets the log level
   b) It returns a child logger that attaches those attributes to every record, so you do not repeat them at each call site
   c) It writes one line immediately
   d) It replaces the handler
   Answer: b. With returns a new *Logger carrying the attributes pre-formatted. It is the usual way to add per-request or per-component context once, at the edge, rather than threading it through every call.

4. Where should the handler and level be decided?
   a) In each package, at init time
   b) Once in main, with libraries taking a *slog.Logger or using slog.Default()
   c) In an environment variable read at every call
   Answer: b. Same rule as every logging library: the application chooses the destination and the verbosity, the library only emits records. A package that installs its own handler takes that choice away from whoever imports it.


