Logs a Machine Can Read: slog in the Standard Library

Structured logging with log/slog — text vs JSON handlers, levels and minimum-level filtering, child loggers with With, groups, the allocation-lean LogAttrs path, and the !BADKEY footgun that go vet catches. Compiled and run against Go 1.26.5.

The service from the last series logged with log.Printf("#%d %s %s -> %d", id, method, path, status). That line is fine for a human reading a terminal and useless the moment logs land in a system that wants to query them. “Show me every request to /books that returned a 500” is a grep and a prayer against a printf string — and a one-field lookup against anything better. Structured logging is that difference: instead of formatting values into a sentence, you attach them as key/value pairs a machine can index. Since 1.21 this lives in the standard library as log/slog, so it’s one import and no dependency decision to agonize over. This chapter is the whole working surface of it, and every line of output below was compiled and run against Go 1.26.5.

A logger, a handler, and a line

The shape of slog is two objects. A *slog.Logger is what you call (Info, Warn, Error, Debug), and a handler is what decides where the record goes and how it’s formatted. You build a logger by wrapping a handler around a destination:

logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Info("server started", "addr", ":8080", "pid", 4123)
logger.Warn("slow query", "ms", 812, "table", "books")

The first argument is the message; everything after it is alternating keys and values. The TextHandler renders that as key=value pairs, logfmt-style:

time=2026-07-31T16:34:31.483-07:00 level=INFO msg="server started" addr=:8080 pid=4123
time=2026-07-31T16:34:31.483-07:00 level=WARN msg="slow query" ms=812 table=books

Three attributes come for free on every line — a time (real, so it changes run to run), a level, and the msg — and then your own keys. Notice that "slow query" got quoted because it contains a space, while books did not: the handler quotes only when it has to, which keeps the common case readable.

The same call, as JSON

Swap the handler and nothing else changes at the call site. That’s the whole design — the what (your log calls) is decoupled from the how (the handler). A JSONHandler turns the identical two lines into machine-parseable JSON:

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("server started", "addr", ":8080", "pid", 4123)
logger.Warn("slow query", "ms", 812, "table", "books")
{"time":"2026-07-31T16:34:32.394034-07:00","level":"INFO","msg":"server started","addr":":8080","pid":4123}
{"time":"2026-07-31T16:34:32.394273-07:00","level":"WARN","msg":"slow query","ms":812,"table":"books"}

This is what you ship to production. Every log aggregator on earth ingests newline-delimited JSON, and each field becomes a queryable column instead of a substring you have to regex out. The rule of thumb writes itself — TextHandler for a human at a terminal, JSONHandler for anything that collects logs. A common pattern is to pick between them from config: text in local dev, JSON everywhere else, which the next chapter’s config loader makes a one-line decision.

Levels, and a minimum that filters

slog has four built-in levels — Debug, Info, Warn, Error — and the one you must configure is the minimum. Below it, records are dropped before they’re ever formatted, so Debug logging costs almost nothing in production when the floor sits at Info. You set that floor through HandlerOptions:

opts := &slog.HandlerOptions{Level: slog.LevelWarn}
logger := slog.New(slog.NewTextHandler(os.Stdout, opts))
logger.Debug("connecting to db")           // dropped
logger.Info("cache warmed", "keys", 128)   // dropped
logger.Warn("disk almost full", "pct", 92) // printed
logger.Error("write failed", "err", "EIO") // printed

With the floor at Warn, the first two calls vanish and only the last two reach the output:

time=2026-07-31T16:34:45.730-07:00 level=WARN msg="disk almost full" pct=92
time=2026-07-31T16:34:45.730-07:00 level=ERROR msg="write failed" err=EIO

A nil options argument — what we passed to the first two handlers — means the default floor of Info, which is why Debug never appears unless you ask for it. For a floor you can turn at runtime without a restart, wrap a *slog.LevelVar in the options and call .Set() on it later; that’s the standard way to flip debug logging on in a live process while you chase a bug, then flip it back.

One subtlety catches people who assume a below-floor log is free. The record is dropped, but its arguments were already evaluated, because Go evaluates a function’s arguments before the call happens. A logger.Debug("trace", "value", expensive()) still runs expensive() even when the floor is Warn:

logger.Debug("trace", "value", expensive()) // dropped, but expensive() runs
logger.Warn("done")
expensive() was called
time=... level=WARN msg=done

The Debug line produced no output, yet expensive() printed. If a debug value is genuinely costly to compute, guard it with logger.Enabled(ctx, slog.LevelDebug) or hand slog a slog.LogValuer that defers the work — don’t rely on the level filter to skip a call that Go has already made.

With: a child logger that carries context

You rarely want to repeat "request_id", id on every log call inside a handler. logger.With(...) returns a child logger that carries a fixed set of attributes, stamped onto every line it emits:

base := slog.New(slog.NewTextHandler(os.Stdout, nil))
slog.SetDefault(base)

reqLog := base.With("request_id", "abc123", "route", "/books")
reqLog.Info("handling")
reqLog.Info("done", "status", 200)

slog.Info("via default logger")
time=... level=INFO msg=handling request_id=abc123 route=/books
time=... level=INFO msg=done request_id=abc123 route=/books status=200
time=... level=INFO msg="via default logger"

Both reqLog lines carry request_id and route without repeating them, and the per-call status=200 slots in alongside. This is the pattern for request-scoped logging: at the edge of a request, derive a child logger With the request id and stash it in the context, and every log line downstream is automatically correlated to that request — no thread-local, no global. The slog.SetDefault(base) call also wires that logger into the package-level slog.Info/slog.Warn functions, so code that just calls slog.Info(...) — including third-party libraries you don’t control — routes through your handler and lands in the same stream.

Groups and the typed, lean path

Two more tools round out the surface. slog.Group nests related attributes under one key, which JSON renders as a sub-object:

logger.Info("request",
	slog.Group("http",
		slog.String("method", "GET"),
		slog.Int("status", 200),
	),
)
{"time":"...","level":"INFO","msg":"request","http":{"method":"GET","status":200}}

And LogAttrs is the call to reach for on a genuinely hot path. The loose "key", value, ... form is convenient, but it passes everything as any, which means boxing and a little allocation per call. LogAttrs takes a context, a level, a message, and typed slog.Attr values — skipping that overhead:

logger.LogAttrs(context.Background(), slog.LevelInfo, "typed",
	slog.String("user", "ada"),
	slog.Int("attempt", 3),
)
{"time":"...","level":"INFO","msg":"typed","user":"ada","attempt":3}

The output is identical to the loose form; the difference is purely in what it costs to produce, and it earns its keep only where you’re logging in a tight loop. For ordinary code the convenient form is the right call, and reaching for LogAttrs everywhere is premature optimization that makes every log line noisier to read.

The !BADKEY footgun (and the vet that saves you)

The loose form has one sharp edge, and it’s the one that bites everyone exactly once. The arguments after the message are pairs, so an odd count leaves a key without a value. slog doesn’t panic and doesn’t drop it — it invents a key so the stray value isn’t lost silently:

logger.Info("user login", "user", "ada", "ip") // "ip" has no value
time=... level=INFO msg="user login" user=ada !BADKEY=ip

That !BADKEY=ip is slog telling you it found a dangling argument and treated "ip" as a value under a placeholder key. The usual cause is meaning "ip" to be a key and forgetting the address after it. The good news is you rarely ship this, because go vet understands the slog calling convention and flags it before you run anything:

$ go vet .
main.go:10:2: call to slog.Logger.Info missing a final value

Wire go vet ./... into CI — as the first chapter of the fundamentals series argued — and this class of bug never reaches a log aggregator. The typed slog.String("ip", addr) form sidesteps it entirely, which is a second, quieter reason to prefer LogAttrs and friends when a log line genuinely matters.

ReplaceAttr: redaction and rewriting

Sometimes the problem isn’t what you log but what leaks into it. A struct you pass along carries a password field, an auth token rides in on a header, a timestamp is more precise than anyone needs. HandlerOptions.ReplaceAttr is the hook for that: a function the handler calls for every attribute of every record, giving you the chance to rewrite it, rename it, or drop it before it’s ever written. It receives the enclosing groups and the slog.Attr, and returns the attribute to emit — or the zero slog.Attr{} to remove it entirely.

func replace(groups []string, a slog.Attr) slog.Attr {
	switch a.Key {
	case "password", "token", "api_key":
		return slog.String(a.Key, "REDACTED") // scrub secrets
	}
	if a.Key == "user" {
		a.Key = "user_id" // rename a key
	}
	if a.Key == "internal" {
		return slog.Attr{} // drop it entirely
	}
	if a.Key == slog.TimeKey && len(groups) == 0 {
		a.Value = slog.StringValue(a.Value.Time().UTC().Format("2006-01-02T15:04:05Z"))
	}
	return a
}

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: replace}))
logger.Info("login attempt",
	"user", "ada", "password", "hunter2", "token", "sk-abc123",
	"internal", "drop me", "ip", "10.0.0.4")

One log call with a plaintext password and token in it, and here is what actually reaches the output:

{"time":"2026-08-01T00:54:55Z","level":"INFO","msg":"login attempt","user_id":"ada","password":"REDACTED","token":"REDACTED","ip":"10.0.0.4"}

Every piece did its job: password and token came out REDACTED, user was renamed to user_id, internal vanished from the record, and the built-in time was reformatted to a coarser stamp. The len(groups) == 0 guard on the time is worth noticing — ReplaceAttr visits nested group attributes too, and the three built-in keys (time, level, msg) only appear at the top level, so matching them without checking the group depth would also rewrite a user key that happened to be named time inside a group. Redaction is the killer use, because it fires no matter which call site logged the secret: one central function scrubs the whole service, so a stray logger.Info("saving", "password", pw) in code you forgot about is still caught.

One related knob lives in the same HandlerOptions: AddSource: true stamps the source file and line onto every record (as a source attribute), which is handy for tracing a log line back to the exact call — at a small cost, since the handler has to walk the call stack for each record.

Final thoughts

slog is the whole structured-logging story in one standard-library package: a logger you call, a handler that decides format and destination, TextHandler for humans and JSONHandler for machines, a minimum level that filters cheaply, With for context you don’t want to repeat, Group for nesting, and LogAttrs for the hot path. The !BADKEY trap is real, but go vet catches it before you do. Replace the log.Printf from the last series with a JSONHandler logger, thread a request-scoped child through the context, and your service’s output stops being prose and starts being data you can actually ask questions of. Next, we stop hard-coding that logger’s level — and everything else — and pull it out of the environment.

Next: configuration from the environment

Comments