Structured Logging in Go Without a Third-Party Library
Since Go 1.21 you don't need zap or zerolog to get structured logs out of Go. For years the standard answer was "add zap, or maybe zerolog, and wire up a couple of hundred lines of adapter code so the rest of your service doesn't have to know which one you picked." That's no longer strictly true.
log/slog is in the standard library, and it does:
- levels;
- key-value pairs;
- JSON output;
- context propagation.
It's also fast enough that the usual excuse for skipping it (performance) mostly evaporates.
This isn't an argument that zap and zerolog are pointless now. It's a walkthrough of what slog actually gives you, because most people who've heard of it are still writing log.Printf("user %s logged in from %s", userID, ip) and grepping their way through unstructured text at 2am.
Formatted strings are for humans, key-value pairs are for machines
A formatted string like the one above is fine for a human tailing a terminal. It's miserable for a machine. If you want to filter every log line where user_id=u_123, or aggregate error rates by endpoint, you end up writing regexes against prose that was never meant to be parsed.
Structured logging just means: stop building a sentence, emit key-value pairs, and let the output format be a rendering detail (JSON, logfmt, whatever your aggregator wants) rather than something baked into the call site.
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("user logged in",
slog.String("user_id", "u_123"),
slog.Int("attempt", 1),
)
}
That produces a single line of JSON with time, level, msg, and your two attributes. Nothing revolutionary so far, every structured logger does this.
Handler, not Logger, is the extension point
*slog.Logger is mostly a thin, convenient wrapper. The actual work happens behind the slog.Handler interface, which has exactly four methods: Enabled, Handle, WithAttrs, and WithGroup.
The standard library ships two implementations, TextHandler and JSONHandler. Because it's an interface, you can wrap one to add behaviour without forking anything. A handler that drops debug logs unless a feature flag is set, or that scrubs a field before it hits stdout, is a couple of dozen lines that delegate to an embedded handler for everything else.
This is the same pattern http.RoundTripper uses for middleware, and it works for the same reason: small interface, easy to decorate.
Attributes dodge the allocation that made logging slow
Here's the bit that's actually interesting rather than just plumbing. A naive structured logger takes key string, value interface{} pairs, which means every int or bool you log gets boxed onto the heap so it can sit inside an interface. That allocation, multiplied across millions of log lines, is where the "logging is slow" reputation comes from.
slog.Attr avoids most of it. A slog.Value is a small struct with a kind tag and a numeric field big enough to hold a string header, an int64, a float64, a bool, or a time, plus a pointer field for anything that doesn't fit.
So slog.Int("attempt", 1) or slog.String("user_id", id) never touches the heap for the common cases. The interface{} boxing only happens if you fall back to slog.Any for a type slog doesn't have a dedicated constructor for.
Honestly, this delights me. It's a genuinely well-thought-out piece of API design for something that looks, at a glance, like it's just wrapping fmt.Sprintf.
Carry request fields in the context, not at every call site
The pattern you actually want in a service is a logger that already carries request-scoped fields. Every log line inside a handler then has the request ID and route attached without every call site repeating them. slog doesn't do this automatically, but it composes cleanly with context.Context:
type ctxKey struct{}
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, ctxKey{}, l)
}
func FromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {
return l
}
return slog.Default()
}
func RequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-ID")
if reqID == "" {
reqID = fmt.Sprintf("%x", time.Now().UnixNano())
}
l := slog.Default().With(
slog.String("request_id", reqID),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
)
next.ServeHTTP(w, r.WithContext(WithLogger(r.Context(), l)))
})
}
Logger.With returns a new logger that carries the extra attributes on every subsequent call. A handler further down the stack just does slog.FromContext(ctx).Warn("rate limited") and gets the request ID for free.
This is the same shape as the errgroup and context-cancellation patterns Go pushes everywhere else: pass the thing through context, pull it back out where you need it.
Groups stop subsystems' keys colliding
If your log aggregator prefers nested objects over a flat namespace, WithGroup wraps subsequent attributes under a key:
logger.WithGroup("http").Info("request handled",
slog.String("method", "GET"),
slog.Int("status", 200),
)
// {"time":"...","level":"INFO","msg":"request handled","http":{"method":"GET","status":200}}
This matters more than it looks. It lets you compose loggers from different subsystems (an HTTP layer, a database layer) without their attribute names colliding, and without every call site having to prefix keys by hand.
Redact secrets on the type, not at every call site
The recurring way logging leaks credentials isn't someone deliberately logging a password. It's someone logging a whole struct that happens to contain one, six months after the struct grew that field.
slog's answer is the LogValuer interface: implement LogValue() slog.Value on the type itself. Every caller who logs it with slog.Any then gets the redacted form automatically, whether they know about the sensitive field or not.
type Credentials struct {
Username string
Password string
}
func (c Credentials) LogValue() slog.Value {
return slog.GroupValue(
slog.String("username", c.Username),
slog.String("password", "REDACTED"),
)
}
// logger.Info("auth attempt", slog.Any("credentials", creds))
// -> {"credentials":{"username":"...","password":"REDACTED"}}
This is a much sturdier guarantee than a code review checklist. The redaction lives next to the field it protects, not in the head of whoever writes the log call.
Three options that are easy to miss
HandlerOptions.ReplaceAttrlets you rewrite or drop any attribute before it's written. That's where you'd strip a field entirely rather than just masking it, or renamemsgtomessageif your log pipeline expects that.AddSource: trueadds the calling file and line. Useful in development, usually worth turning off in production for the extra stat call it costs per log line.slog.NewLogLoggerhands you a bridge that writes through your handler, for APIs that want an old-style*log.Logger, such ashttp.Server.ErrorLog. You don't need two logging configurations in the same binary.
Prefer the typed constructors over the shorthand
One easy mistake: slog's variadic API takes alternating key-value arguments when you use the shorthand form (logger.Info("msg", "key", value)). A missing value produces a log line with !BADKEY instead of a compile error.
Prefer the typed constructors (slog.String, slog.Int, and so on) over the shorthand. They're not meaningfully more typing, and they turn that mistake into a type error instead of a silently malformed log line.
Where zap and zerolog still earn their place
Where zap or zerolog still earn a place in go.mod is genuinely high-throughput logging, tens of thousands of lines a second. Two things show up there:
- their more aggressive avoidance of allocations shows up in a profile (zerolog in particular is built around not allocating at all on the hot path);
- built-in sampling helps avoid flooding a log pipeline during an incident.
For the overwhelming majority of services, though, slog gets you structured, leveled, context-aware logging with a redaction story. And it does it without adding a dependency whose API you're then stuck maintaining compatibility with across major versions.