Go 1.21’s slog: The Future of Structured
Why slog Matters in 2026
Before Go 1.21, structured logging in Go meant picking a third-party library. The community had zap from Uber for raw throughput, zerolog for zero-allocation JSON, and logrus for developer ergonomics. Each library had its own API, its own handler interface, and its own way of attaching context. Teams that switched libraries had to rewrite every log statement.
Go 1.21 changed that with log/slog. As the official Go blog announced, slog provides a native, high-performance structured logging API designed to be the standard frontend for Go logging. It separates the logger interface (frontend) from the handler implementation (backend), meaning you can swap output formats and destinations without changing a single log call in your application code.
By 2026, slog has become the default choice for new Go microservices. The Dash0 practitioner’s guide notes that slog is not just another logger — it is a new foundation that provides a common API across the entire Go ecosystem. Third-party handlers from companies like Datadog, Grafana, and Honeycomb now implement the slog.Handler interface, making slog the interoperability layer for Go observability.

Setting Up slog for Production
A production-ready slog setup follows a few hard rules: write JSON to stdout, attach stable service metadata once, and use context-aware methods for trace correlation. Here is a minimal setup that covers most microservice deployments in 2026:
package main
import (
"log/slog"
"os"
"runtime/debug"
)
func NewProdLogger(serviceName string) *slog.Logger {
buildInfo, _ := debug.ReadBuildInfo()
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelInfo,
})
logger := slog.New(handler).With(
slog.String("service", serviceName),
slog.String("go_version", buildInfo.GoVersion),
slog.Int("pid", os.Getpid()),
)
return logger
}
func main() {
logger := NewProdLogger("payment-api")
slog.SetDefault(logger)
logger.Info("server starting",
slog.String("env", "prod"),
slog.Int("port", 8080),
)
}
Three things to notice. First, logs go to os.Stdout, not to files. Writing to files inside a container is an anti-pattern — the container runtime and log shipper handle rotation and transport. Second, AddSource: true includes the file and line number of each log call, which is invaluable during debugging but does add a small allocation cost. The Better Stack guide recommends enabling it in staging and production for all but the highest-throughput services. Third, service metadata is attached once via With() so every log record carries the service name and Go version without repeating it at each call site.

Context Propagation and Trace Correlation
The single most important pattern for microservice logging is embedding trace context into every log record. Without it, logs from different services are just disconnected blobs of text. With it, you can filter every log entry that belongs to a single request across five services.
slog provides InfoContext(), ErrorContext(), and similar methods that accept context.Context. The built-in handlers do not automatically extract values from context — you need a custom handler or middleware to do that. Here is a complete example that wraps the standard JSON handler to inject OpenTelemetry trace and span IDs:
package main
import (
"context"
"log/slog"
"os"
"go.opentelemetry.io/otel/trace"
)
type TraceHandler struct {
slog.Handler
}
func NewTraceHandler(h slog.Handler) *TraceHandler {
return &TraceHandler{Handler: h}
}
func (h *TraceHandler) Handle(ctx context.Context, r slog.Record) error {
spanCtx := trace.SpanContextFromContext(ctx)
if spanCtx.IsValid() {
r.AddAttrs(
slog.String("trace_id", spanCtx.TraceID().String()),
slog.String("span_id", spanCtx.SpanID().String()),
)
if spanCtx.IsSampled() {
r.AddAttrs(slog.Bool("trace_sampled", true))
}
}
return h.Handler.Handle(ctx, r)
}
func (h *TraceHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return NewTraceHandler(h.Handler.WithAttrs(attrs))
}
func (h *TraceHandler) WithGroup(name string) slog.Handler {
return NewTraceHandler(h.Handler.WithGroup(name))
}
func main() {
baseHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelInfo,
})
logger := slog.New(NewTraceHandler(baseHandler))
}
This pattern is documented in the OneUptime guide to structured logging with OpenTelemetry. The key detail is that Handle() is called once per log record, so trace ID extraction happens at record creation time, not at call time. The WithAttrs and WithGroup methods must also return wrapped handlers so that child loggers (created via logger.With(...)) still inject trace context.
The Go FAQ guide to microservice logging describes this as a “passport stamp” approach: every service stamps its trace ID onto each log entry, letting you follow a request across service boundaries just by searching for that ID.
Common Pitfalls and How to Avoid Them
Teams adopting slog in production hit the same problems repeatedly. Here are the three most common, with concrete fixes.
Pitfall 1: Unbalanced key-value pairs. slog’s convenience API accepts alternating keys and values after the message string. If you accidentally pass an odd number of arguments, slog silently creates a !BADKEY entry. The Dash0 guide calls this an “API footgun” that can corrupt your logging data during a critical incident. The fix is twofold: always use strongly-typed slog.Attr helpers like slog.String() and slog.Int(), and enforce this with the sloglint linter in your CI pipeline.
logger.Info("request failed", "user_id", 12345, "error")
logger.Info("request failed",
slog.Int("user_id", 12345),
slog.String("error", err.Error()),
)
logger.LogAttrs(context.Background(), slog.LevelError,
"request failed",
slog.Int("user_id", 12345),
slog.String("error", err.Error()),
)
Pitfall 2: Expensive arguments at disabled log levels. If you compute a value only to pass it to a Debug() call that gets suppressed, you waste CPU. Check logger.Enabled() before performing expensive work:
if logger.Enabled(context.Background(), slog.LevelDebug) {
logger.Debug("query plan", slog.String("plan", computeExpensivePlan()))
}
Pitfall 3: Static log levels that require a restart to change. Production incidents often require temporarily raising log verbosity without redeploying. Use slog.LevelVar for dynamic level control:
var logLevel slog.LevelVar
logLevel.Set(slog.LevelInfo)
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: &logLevel,
})
logger := slog.New(handler)
The Better Stack guide also recommends the slog-env package for per-package level control via the GO_LOG environment variable, which lets you raise verbosity for a single package without affecting the rest of the service.
slog vs zap vs zerolog: Performance Trade-Offs
Performance is the most common reason teams hesitate to adopt slog. The Better Stack community benchmarks provide the most comprehensive comparison. The numbers below are drawn from those benchmarks and from the Dash0 logging library comparison published in June 2026.
| Metric | slog (JSONHandler) | zap (production) | zerolog |
|---|---|---|---|
| Messages per second (typical) | ~2.5 million | ~5 million | ~6 million |
| Allocations per log call | 2-3 | 0-1 | 0 |
| Standard library integration | Native (no dependency) | External dependency | External dependency |
| Handler interface compliance | Built-in | Via slog adapter | Via slog adapter |
| Context-aware methods | Yes (InfoContext, etc.) | Manual | Manual |
The headline takeaway: slog is about 2x slower than zerolog and zap in raw throughput benchmarks. For most microservices, that difference does not matter. A typical Go HTTP service handles thousands of requests per second and logs a handful of lines per request. The overhead of slog is measured in microseconds per log call, dwarfed by network I/O, database queries, and JSON serialization of response bodies.
Where the gap does matter is in high-throughput logging scenarios: audit trails that log every database write, packet-level debugging, or services that emit tens of thousands of log lines per second. In those cases, the Stackademic migration report notes that teams either stay on zap/zerolog or use slog’s backend adapter to route through a zap handler while keeping the slog frontend API. The LeapCell migration guide confirms that this hybrid approach gives you the best of both worlds: a standard API with zap-level throughput.
Production Patterns for Microservices
Beyond the basics, teams running slog in production across dozens or hundreds of microservices converge on a set of shared patterns.
The factory reads environment variables for log level, output format (JSON or text), and whether to include source locations. This ensures every service in the organization emits logs in the same format with the same metadata schema.
Structured error wrapping. Go errors carry no structured metadata by default. The pattern in 2026 is to wrap errors with slog attributes so that the error handler can log them without manual extraction:
type LoggedError struct {
Err error
Attr []slog.Attr
}
func (e *LoggedError) Error() string { return e.Err.Error() }
func (e *LoggedError) Unwrap() error { return e.Err }
func LoggedErrorContext(ctx context.Context, logger *slog.Logger, err error) {
var logged *LoggedError
if errors.As(err, &logged) {
logger.ErrorContext(ctx, logged.Error(), logged.Attr...)
} else {
logger.ErrorContext(ctx, err.Error())
}
}
Log sampling for high-volume paths. Health checks, liveness probes, and keep-alive endpoints generate noise. Wrap the handler with a sampler that drops INFO and below for known high-frequency paths, or use slog’s Leveler interface to raise the effective level per path. The Glukhov guide shows a custom handler that implements this pattern with a map of path-to-level overrides.
Structured panic recovery. HTTP middleware that recovers from panics should log the stack trace as structured data, not as a formatted string. slog’s Any() method can serialize the stack as a JSON array, making it queryable in your log aggregator:
func RecoveryMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
stack := make([]byte, 4096)
n := runtime.Stack(stack, false)
logger.ErrorContext(r.Context(), "panic recovered",
slog.Any("panic", rec),
slog.String("stack", string(stack[:n])),
)
w.WriteHeader(http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
Schema normalization. The biggest long-term cost of structured logging is schema drift. One team logs user_id, another logs userId, and a third logs UserID. By the time you have 50 microservices, querying across them becomes painful. Enforce a shared attribute naming convention at the logger factory level, and use a custom ReplaceAttr function to rename or reject non-conformant keys.

Key Takeaways
- Go 1.21’s
log/slogis the standard structured logging frontend for Go in 2026, providing a native API that separates the logger interface from the handler implementation. - Always write JSON to stdout, attach stable service metadata once via
With(), and use context-aware methods (InfoContext,ErrorContext) for trace correlation across microservices. - Use strongly-typed
slog.Attrhelpers and enforce them with sloglint to avoid the!BADKEYsilent corruption bug. - slog is about 2x slower than zap/zerolog in raw throughput, but the difference is negligible for most services. For high-throughput paths, use the slog adapter for the zap backend.
- Adopt centralized logger initialization, structured error wrapping, and attribute naming conventions to prevent schema drift as your microservice fleet grows.
- Use
slog.LevelVarfor dynamic log level control that does not require a restart — essential for debugging production incidents without redeployment.
slog has matured into the default choice for Go microservice logging in 2026. Its standard library status means zero external dependencies, its handler interface makes it extensible across every major observability platform, and its context-aware methods solve the trace correlation problem that plagued earlier approaches. The performance gap with zap and zerolog is real but narrow, and the ecosystem benefits of a unified API outweigh the throughput cost for the vast majority of production workloads.
Sources and References
Sources cited while researching and writing this article:
- zap
- zerolog
- logrus
- official Go blog announced
- Logging in Go with Slog: A Practitioner’s Guide – Dash0
- Logging in Go with Slog: The Ultimate Guide | Better Stack Community
- How to Set Up Structured Logging in Go with OpenTelemetry
- Logging Best Practices for Go Microservices , Go FAQ
- sloglint
- slog-env
- GitHub – betterstack-community/go-logging-benchmarks: A comparison of …
- Choosing a Go Logging Library in 2026 – Dash0
- We Migrated from Zap to slog. Eight Months Later, Here Is the Honest …
- Deep Dive and Migration Guide to Go 1.21+’s Structured Logging …
Thomas A. Anderson
Mass-produced in late 2022, upgraded frequently. Has opinions about Kubernetes that he formed in roughly 0.3 seconds. Occasionally flops, but don't we all? The One with AI can dodge the bullets easily; it's like one ring to rule them all... sort of...
