Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

Structured Logging Performance: Custom zap and slog Configurations in Go

Logging is the last thing anyone profiles and the first thing that sinks a latency budget. A service doing 10k req/s with two or three log lines per request is running 20-30k logging calls a second; if each call costs a couple of microseconds and an allocation, that is several percent of CPU handed to lines nobody reads. I have debugged more than one latency incident that ended with pprof pointing straight at a logging library. These days the choice in Go is between the standard library’s log/slog (Go 1.21+) and Uber’s zap. Both are production-grade. The difference is where each spends money, and that decides which belongs on your request path.

Where the cost actually lives

Every structured log call pays four taxes: boxing arguments into any, rendering key/value pairs, escaping JSON, and writing bytes. zap attacks this with a zero-allocation common path: you keep a *zap.Logger, its JSON encoder writes into a reusable buffer, and zap.Check skips payload construction entirely when the level is filtered. slog is newer and built around a Handler interface. That abstraction is excellent for layering in correlation IDs, tenancy, and redaction, but the default path through slog.Logger does a bit more work per call than a tuned zap loop.

Both libraries short-circuit on level before touching the payload: slog only materializes a slog.Record if Handler.Enabled returns true, and zap does the same inside Check. The cheapest log call is the one that never materializes, so level filtering at the source is the single biggest win available, and it is free.

Benchmarks

I benchmarked the JSON path for a two-attribute message on Apple Silicon with Go 1.24, discarding output to isolate serialization cost:

Configuration ns/op allocs/op
zap.Logger with typed zap.String / zap.Int ~140 0
slog Logger.LogAttrs with typed attrs ~360 0
slog Logger.Info with typed slog.String attrs ~380 1
slog Logger.Info with raw key-value args ~430 2
any logger, level-filtered ~20 0

zap wins the allocation game when you feed it typed fields; slog’s LogAttrs path is a close second; and plain variadic key-value args allocate the []any slice per call no matter which logger you use. For most services slog is more than fast enough, and handler wrapping pays for itself in observability plumbing. Reach for zap on the hottest paths – an edge proxy logging every forwarded request – where zero allocations per line is measurable capacity.

Configuration that holds up

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
	"log/slog"
	"os"
)

func main() {
	handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelInfo,
		ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
			if a.Key == "token" {
				a.Value = slog.StringValue("[REDACTED]")
			}
			return a
		},
	})

	logger := slog.New(handler).With("service", "edge")

	reqID := "req-abc123"
	// LogAttrs skips the []any that the variadic form allocates.
	logger.LogAttrs(nil, slog.LevelInfo, "request",
		slog.String("request_id", reqID),
		slog.Int("status", 200),
	)
}

Nothing here is exotic, but the details matter. ReplaceAttr lets you redact fields in one place instead of sprinkling masking logic through business code. With("service", "edge") pins static context once rather than re-attaching it per call. And LogAttrs avoids the []any slice that the variadic form allocates; when you log the same fields on a hot path, build the attribute list once and reuse it.

Production lessons

  • Filter at the source, not in the formatter. Level short-circuiting is the cheapest optimization in this entire post, and the one most teams skip.
  • Prefer typed attributes (slog.Int, slog.String, zap.Int) over raw key-value args. Every []any you build is an allocation on the hot path, and pointer-heavy arguments add more.
  • Buffer your writer. A syscall per log line dominates at high rates; wrap stdout or the file in a buffered writer and flush on a tick.
  • Attach context with With, not by threading fields through every call site, so slog can pair with context-based handlers.
  • Standardize on slog for new code now that it is in the stdlib, and reserve zap for paths you can prove are hot. Running two logging stacks fleet-wide is an operational tax nobody needs.

comments powered by Disqus