/user/kayd @ devops :~$ cat go-logging.md

Go Logging Cheatsheet (log/slog) Go Logging Cheatsheet (log/slog)

QR Code linking to: Go Logging Cheatsheet (log/slog)
Karandeep Singh
Karandeep Singh
• 6 minutes

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.

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

Expand your knowledge with Ansible Cheatsheet

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
the same calls through both handlers
$ go run slogdemo.gotime=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

slog.LevelDebug   // -4
slog.LevelInfo    //  0   the default
slog.LevelWarn    //  4
slog.LevelError   //  8
// 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).

Deepen your understanding in Python Logging Cheatsheet

Attributes, and the One Real Trap

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

Explore this further in Bash Basics Cheatsheet

an odd argument count
$ 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.
git diff — deploy.gounmissable at review time
@@ -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
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))
note
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.

Get new articles by email

One DevOps article a week, plus the 18-cheatsheet PDF pack. No spam, one click to leave.

Context: Attributes That Follow a Request

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.

Discover related concepts in Python Logging Cheatsheet

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

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.

Uncover more details in AWS CloudFormation Cheatsheet

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:

Journey deeper into this topic with Bash sed & awk Cheatsheet

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
    },
}
caution
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

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.

Enrich your learning with Bash CLI One-Liners Cheatsheet

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?

Gain comprehensive insights from Ansible Cheatsheet

Similar Articles

More from development

Go (Golang) Cheatsheet

Go cheatsheet covering the go toolchain, syntax essentials, goroutines and channels, table-driven …

Bash Basics Cheatsheet

Quick-reference Bash basics cheatsheet: variables, parameter expansion, conditionals, loops, arrays, …

Go Security Cheatsheet

Go security cheatsheet: exec.Command without a shell, crypto/rand vs math/rand, SQL parameters, path …

Knowledge Quiz

Test your general knowledge with this quick quiz!

A set of multiple-choice questions to test your knowledge.

Take as much time as you need.

Your score will be shown at the end.

Get new articles by email

One DevOps article a week, plus the 18-cheatsheet PDF pack. No spam, one click to leave.

🍪 We use cookies for analytics. Read the privacy policy