Prefer log/slog (Go 1.21+) over the older log package. It's the standard library's structured, leveled logger, so you get JSON/key-value output without pulling in a third-party dependency.
Three core types to know:
Logger: the frontend you call (Info(), Error(), etc.)Record: the self-contained log event created per callHandler: an interface that controls formatting and destination. Two are built-in: TextHandler (logfmt-style key=value) and JSONHandler.Don't rely on the package-level default logger in production. It uses TextHandler and writes plain text, not structured JSON, so build your own:
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("Info message")
// Output:
// {"time":"2024-01-03T10:24:22.227408691+01:00","level":"INFO","msg":"Info message"}
The frontend/backend split is the real benefit: you write logging calls once, against slog.Logger, and never touch them again even if the backend changes. Say you start with the default JSONHandler and later decide you want Zap's performance and features under the hood. You don't rewrite a single logger.Info() call across the codebase, you just swap the Handler passed into slog.New():
// Before: plain JSONHandler
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// After: Zap as the backend, same slog.Logger API on top
zapL := zap.Must(zap.NewProduction())
logger := slog.New(zapslog.NewHandler(zapL.Core(), nil))
logger.Info("incoming request", slog.String("method", "GET"), slog.Int("status", 200))
// Output:
// {"level":"info","ts":1697453912.4535635,"msg":"incoming request","method":"GET","status":200}
This is also what makes it painless to trial different backends (Zap, Zerolog, a custom handler) in production without a migration project. Same reasoning applies going the other way: if you later want to drop Zap, you swap the handler back and every log statement in your app keeps working unchanged.
All loggers default to INFO level, which silently drops Debug() calls. Set the level explicitly via HandlerOptions:
opts := &slog.HandlerOptions{Level: slog.LevelDebug}
handler := slog.NewJSONHandler(os.Stdout, opts)
Use slog.SetDefault() if you still have legacy code calling log.Println(). It redirects the old log package's output through your new structured handler, letting you migrate incrementally instead of rewriting every call site at once.
Level methods accept a message, then loose key, value, key, value... pairs:
logger.Info("incoming request", "method", "GET", "status", 200)
// Output:
// {"time":"2024-01-03T10:24:22Z","level":"INFO","msg":"incoming request","method":"GET","status":200}
Risk: unbalanced pairs fail silently. A missing value gets logged under the key !BADKEY instead of causing a compile error, so a typo can quietly corrupt your logs in production.
Prefer strongly-typed attributes (slog.String, slog.Int, etc.) to catch mismatches:
logger.Info("incoming request", slog.String("method", "GET"), slog.Int("status", 200))
// Output:
// {"time":"2024-01-03T10:24:22Z","level":"INFO","msg":"incoming request","method":"GET","status":200}
For guaranteed type safety, use LogAttrs(). It only accepts slog.Attr, so unbalanced pairs are impossible, at the cost of a more verbose signature (you must also pass a context and the level explicitly):
logger.LogAttrs(context.Background(), slog.LevelInfo, "incoming request",
slog.String("method", "GET"), slog.Int("status", 200))
// Output:
// {"time":"2024-01-03T10:24:22Z","level":"INFO","msg":"incoming request","method":"GET","status":200}
Run go vet or a linter like sloglint to catch bad key/value pairs and enforce consistent key-naming conventions before they ship.
slog.Group() nests related attributes under one key. With JSONHandler this produces a nested object; with TextHandler keys get a dotted prefix (properties.width=4000).
Use Logger.With() to create a child logger that carries shared fields (request ID, PID, version) into every subsequent log call, so you don't repeat them at each call site:
child := logger.With(slog.String("request_id", "req-123"))
child.Info("image upload successful")
// Output:
// {"time":"2024-01-03T10:24:22Z","level":"INFO","msg":"image upload successful","request_id":"req-123"}
WithGroup() nests everything the child logger writes (including fields added later, at the log point) under a single group name.