Structured logging with log/slog¶
log/slog is the standard library's structured logger. Every entry is
a message plus typed key-value attributes, which means logs can be
queried rather than grepped.
slog.Info("server started", "port", 8080, "tls", false)
// level=INFO msg="server started" port=8080 tls=false
Two handlers¶
The logger formats nothing itself — a Handler does. Two ship with the
standard library:
slog.New(slog.NewTextHandler(os.Stdout, nil)) // human-readable
slog.New(slog.NewJSONHandler(os.Stdout, nil)) // machine-readable
jl.Info("request", "method", "GET", "path", "/users", "ms", 12)
// {"level":"INFO","msg":"request","method":"GET","path":"/users","ms":12}
The usual arrangement is text locally and JSON in production, decided once at startup:
func init() {
if os.Getenv("ENVIRONMENT") != "local" {
opts := &slog.HandlerOptions{Level: parseLevel(os.Getenv("LOG_LEVEL"))}
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, opts)))
}
}
slog.SetDefault makes the package-level slog.Info and friends use
your handler, so library code logs through it without being passed a
logger.
Log to stdout, not a file. In a container something else collects it; writing to a file means log rotation is now your problem.
Attributes¶
The loose form alternates keys and values:
It is concise, and unchecked — an odd number of arguments produces a
!BADKEY entry rather than a compile error. The typed form avoids
that and is faster, since nothing has to be boxed:
slog.Info("started",
slog.Int("workers", 4),
slog.Duration("timeout", 5*time.Second),
)
// {"level":"INFO","msg":"started","workers":4,"timeout":5000000000}
Note Duration serialises as nanoseconds in JSON. If your log platform
wants milliseconds, pass slog.Int64("timeout_ms", d.Milliseconds())
instead.
slog.Any covers types without a dedicated constructor.
Levels¶
Four levels, as Debug, Info, Warn, Error. They are ordered
integers, so filtering is a comparison:
fmt.Println(slog.LevelWarn > slog.LevelInfo) // output: true
fmt.Println(int(slog.LevelError)) // output: 8
Set the threshold on the handler:
Anything below it is dropped cheaply — the arguments are not even formatted. Make the level configurable by environment variable so production can be turned up without a deploy.
Error means a human should act. See
project conventions
for what each level is for; the short version is log a failure once, at
the level that handles it.
With for context that repeats¶
With returns a logger carrying attributes, so you set them once:
log := slog.Default().With(
slog.String("service", "api"),
slog.String("version", "1.2.3"),
)
log.Info("started", slog.Int("workers", 4))
// {"level":"INFO","msg":"started","service":"api","version":"1.2.3","workers":4}
This is the main tool for correlating entries. A logger created per request with the request id attached means every line from that request is findable, without threading the id through every call.
WithGroup and slog.Group nest attributes, which keeps names from
colliding:
slog.Info("db query",
slog.Group("db", slog.String("table", "users"), slog.Int("rows", 3)),
)
// {"level":"INFO","msg":"db query","db":{"table":"users","rows":3}}
LogValuer keeps secrets out¶
A type can control its own logged representation. This is the reliable way to stop a password reaching the logs — reliable because it works everywhere the value is logged, not just where someone remembered:
type User struct {
Name string
Password string
}
func (u User) LogValue() slog.Value {
return slog.GroupValue(slog.String("name", u.Name))
}
slog.Info("login", "user", User{Name: "ada", Password: "hunter2"})
// {"level":"INFO","msg":"login","user":{"name":"ada"}}
The password is gone. Implement LogValue on every type that holds a
credential, a token, or personal data, and the redaction follows the
type around.
It also defers work: LogValue is only called if the entry is actually
emitted, so an expensive representation costs nothing at a filtered
level.
The Context variants¶
InfoContext, ErrorContext and friends pass a context to the
handler. On their own they do nothing visible — the point is that a
custom handler can pull values out of it:
type ctxHandler struct{ slog.Handler }
func (h ctxHandler) Handle(ctx context.Context, r slog.Record) error {
if id, ok := ctx.Value(ctxKey{}).(string); ok {
r.AddAttrs(slog.String("request_id", id))
}
return h.Handler.Handle(ctx, r)
}
slog.Handler is an interface with four methods:
type Handler interface {
Enabled(context.Context, Level) bool
Handle(context.Context, Record) error
WithAttrs(attrs []Attr) Handler
WithGroup(name string) Handler
}
Embedding slog.Handler means you inherit all four and override only
what you need — the same embedding trick as the
middleware status
recorder. There is one trap. The inherited WithAttrs and WithGroup
return the inner handler, so cl.With("user", "ada") gives you a
logger without your wrapper, and request_id quietly disappears.
Override those two as well, so they wrap their result again:
func (h ctxHandler) WithAttrs(as []slog.Attr) slog.Handler {
return ctxHandler{h.Handler.WithAttrs(as)}
}
func (h ctxHandler) WithGroup(name string) slog.Handler {
return ctxHandler{h.Handler.WithGroup(name)}
}
cl.With("user", "ada").InfoContext(ctx, "handled")
// {"level":"INFO","msg":"handled","user":"ada","request_id":"req-42"}
This is how a trace id gets onto every log line automatically. Use the
Context variants by default; they cost nothing and enable this later.
Asserting on logs in a test¶
Swap the default handler for one writing to a buffer:
var buf bytes.Buffer
old := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
defer slog.SetDefault(old)
slog.Warn("careful", "n", 1)
// time=2026-09-23T10:15:02.000+02:00 level=WARN msg=careful n=1
With a JSON handler you can decode each line and assert on fields
rather than matching text. Restore the previous default — t.Cleanup is
the right place — or you leak the buffer into every later test.
HandlerOptions.ReplaceAttr is what makes output deterministic. The
handler calls it for every attribute before writing it, and returning
an empty slog.Attr drops that attribute. Drop the time, and the lines
become comparable:
opts := &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey && len(groups) == 0 {
return slog.Attr{} // drop the time attribute
}
return a
},
}
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, opts)))
slog.Warn("careful", "n", 1)
// level=WARN msg=careful n=1
The older log package¶
log.Printf and log.Fatal still exist and still appear in small
programs. Two things to know: log.Fatal calls os.Exit, so deferred
functions do not run; and its output is unstructured, so it cannot be
queried. For a service, use slog.
From Python: this is
structlog, in the standard library, and with no logger hierarchy — nogetLogger(__name__), no propagation, nodictConfig. You build a handler, set a default, and pass loggers explicitly.LogValueris__repr__for logs, and it is the piece Python's logging has no good equivalent for.
Quick reference¶
| Task | Form |
|---|---|
| a handler | slog.NewJSONHandler(os.Stdout, opts) / NewTextHandler |
| make it the default | slog.SetDefault(slog.New(h)) |
| log | slog.Info("msg", "key", value) |
| typed, checked attrs | slog.Int("n", 4), slog.String(...) |
| threshold | &slog.HandlerOptions{Level: slog.LevelInfo} |
| repeated fields | logger.With(slog.String("service", "api")) |
| nesting | slog.Group("db", ...) |
| redact a type | implement LogValue() slog.Value |
| values from a context | InfoContext plus a handler that reads it |
| test it | swap the default for a buffer handler, restore after |
| deterministic output | HandlerOptions.ReplaceAttr to drop time |