Short version for the impatient: I ripped go.uber.org/zap out of a mid-sized Go backend last quarter and replaced it with the stdlib log/slog. Log volume stayed the same. The dependency tree got smaller. Nothing caught fire. If you want to know how I actually wired it up, and what I still miss from zap, read on.
I resisted this for a while. zap is fast, well-known, and I had been copy-pasting the same zap.NewProduction() setup across services for years. But once slog landed in the stdlib and the surrounding ecosystem stopped being weird, the argument for a third-party logger got thin. What follows is the exact slog shape I now ship, plus the few sharp edges I hit that the official docs kind of glide over.
Why I finally stopped reaching for zap
Honestly, slog isn’t dramatically better than zap. In micro-benchmarks zap is still faster on hot loops, and its SugaredLogger API is nicer than slog.Info(msg, "key", val) for people who hate typing key-value pairs. So the case for switching isn’t performance. The case is that slog is in the standard library, which means every dependency I pull in will eventually accept a *slog.Logger and log the way I want it to log. That’s already happening with the newer net/http, database/sql/driver wrappers I use, and a couple of internal libraries at work.
The other thing that pushed me: when you’re paged at 3am, the fewer moving parts under your logging pipeline, the better. slog is boring. Boring in a production log library is a compliment.
If you want the full backstory on the design, the Go blog’s slog announcement and the pkg.go.dev reference for log/slog are the two sources that helped me most. Read them in that order.
The before/after that convinced my team
Here’s what a request-scoped log line looked like in our zap setup:
// old: zap, sugared
logger.Infow("payment settled",
"user_id", user.ID,
"amount_cents", amt,
"provider", "stripe",
"request_id", reqID,
)
And here’s the same line in slog after migration:
// new: stdlib slog
logger.InfoContext(ctx, "payment settled",
slog.String("user_id", user.ID),
slog.Int64("amount_cents", amt),
slog.String("provider", "stripe"),
)
Notice the request_id disappeared from the call site. That’s not a bug, it’s the whole point of the context-based approach I’ll get to in a second.
The one handler I actually use in prod
I’ve tried the fancy handlers. slog-multi, tint, custom ones that wrap OpenTelemetry. In production I use exactly one: the stdlib slog.NewJSONHandler, configured once at process start, and that’s it.
func newLogger() *slog.Logger {
opts := &slog.HandlerOptions{
Level: slog.LevelInfo,
AddSource: false, // flip to true only when debugging locally
}
h := slog.NewJSONHandler(os.Stdout, opts)
return slog.New(h)
}
Three things I learned the hard way here.
First, AddSource: true sounds harmless, but on a busy request handler it eats a surprising amount of CPU because the runtime has to walk the stack for every log line. Leave it off in prod. Turn it on when you’re actually staring at logs on your laptop.
Second, os.Stdout is the right destination. Not a file. Not a syslog socket. Let the runtime (systemd, Docker, Kubernetes, whatever) capture stdout and ship it. If you fight this, you’ll regret it.
Third, set the default logger. slog.SetDefault(logger) at the top of main() means every library that uses slog package-level calls picks up your config for free. Skipping this line was my single biggest “why aren’t these logs formatted right” bug in the first week.
Context, request IDs, and the pattern that stuck
The reason I dropped zap’s With() chains is that slog finally has a clean context story. My rule: request-scoped fields go into the context, everything else goes at the call site. That means the handler adds them once, and no one has to remember to pass the logger down through every function signature.
Here’s the middleware I run at the edge of every HTTP handler:
func requestLogger(base *slog.Logger) func(http.Handler) http.Handler {
return func(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 = randomID()
}
l := base.With(
slog.String("request_id", reqID),
slog.String("path", r.URL.Path),
slog.String("method", r.Method),
)
ctx := context.WithValue(r.Context(), loggerKey{}, l)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func fromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok {
return l
}
return slog.Default()
}
Downstream, any handler grabs the logger like this:
func handleCharge(w http.ResponseWriter, r *http.Request) {
log := fromContext(r.Context())
log.InfoContext(r.Context(), "charge started", slog.Int64("amount_cents", amt))
// ...
}
One thing to be honest about: this isn’t a slog-only pattern. You could do the same with zap. But the reason it feels less awkward now is that InfoContext, WarnContext, and friends were added specifically so the handler can also inspect the context. If you use OpenTelemetry, that’s how trace IDs get pulled in automatically by a wrapping handler. I don’t currently pipe traces into logs, but if I did, I’d add an OTel handler here and I would not have to change a single call site. That kind of layered composition is much easier to get right in the stdlib version.
My Go concurrency patterns writeup covers the sister pattern for propagating context through goroutines. If you’re doing structured logging without also passing ctx.Done() down into your workers, you’re going to lose logs on shutdown.
Groups and With: how I keep noise out of prod logs
The two slog features I use every day are With (for shared attrs) and groups (for namespacing). Grouping matters more than it looks.
Say a payment call touches three subsystems: your DB, a Stripe SDK, and an internal fraud check. Without groups, you get 12 fields at the top level of every log line and your grep-ability tanks. With groups, you get:
log.InfoContext(ctx, "charge settled",
slog.Group("db",
slog.Int("rows", 3),
slog.Duration("latency", dbLatency),
),
slog.Group("stripe",
slog.String("charge_id", chargeID),
slog.Int("http_status", 200),
),
slog.Group("fraud",
slog.Float64("score", 0.12),
),
)
Which lands in the JSON output as nested objects. In Datadog, Grafana Loki, or whatever you’re using, this is a lot easier to query than a flat forest of keys. I wish I’d started with groups from day one instead of retrofitting them later.
The other thing I do heavily is logger.With at package init for library-style code:
var log = slog.Default().With(slog.String("component", "billing"))
That one line, at the top of each internal package, means every log line from that package is instantly filterable by component. It’s the same trick people use with prefixed loggers in other languages, only cleaner because With returns a new logger and doesn’t mutate global state.
Levels, sampling, and the hot-path problem
Here’s where I was annoyed with slog for about a week. It doesn’t ship with sampling. zap has a very nice sampler.Core that lets you say “emit the first 100 of each log line per second, then drop”. When you’re running a request-per-millisecond service and a downstream starts throwing errors, zap sampling saves your log bill. slog makes you build that yourself.
The workaround I use is a small wrapping handler with a per-message counter:
type samplingHandler struct {
inner slog.Handler
counts sync.Map // map[string]*atomic.Int64
every int64
}
func (h *samplingHandler) Handle(ctx context.Context, r slog.Record) error {
v, _ := h.counts.LoadOrStore(r.Message, new(atomic.Int64))
n := v.(*atomic.Int64).Add(1)
if n%h.every != 0 {
return nil
}
return h.inner.Handle(ctx, r)
}
// Enabled, WithAttrs, WithGroup omitted for brevity: delegate to inner.
Not as clever as zap’s tick-based sampler, but it’s fifty lines and I understand every one of them. For the noisier services I’ve since switched to letting the ingest side (Vector, in our case) do the sampling, which is where I probably should’ve been doing it all along.
If you want a longer, less opinionated look at handler internals, the Go team’s guide to writing a handler is the closest thing to a canonical reference.
What I still miss from zap
Two things.
One, the Sugar() API is genuinely nicer for exploratory code. sugar.Infow("msg", "k", v) reads better than slog.Info("msg", slog.Any("k", v)). slog’s key-value variadic form (slog.Info("msg", "k", v)) exists but the type safety is weaker and you lose autocomplete. I now use slog.String, slog.Int64, and friends religiously, but it feels a hair more ceremonial than the zap version.
Two, zap has better first-party integrations with a lot of the observability vendors. Datadog’s Go SDK, for example, still assumes zap in most examples. That’ll fix itself over the next year, but if you’re setting up a brand new service today, budget an afternoon to figure out the right handler for your log backend.
Also: I still keep zap in my monorepo for one specific service, our real-time market data feed, where the extra 30% throughput matters. Sometimes the right answer is “use both”. I write about that kind of pragmatic trade-off in the work I put on my portfolio, because pretending every service should look identical is how you end up with pain later.
Try this next week
Grab whichever Go service in your stack has the most tangled log call sites. Add slog.SetDefault(newLogger()) in main. Delete one zap.Logger field from a struct and replace it with a call to fromContext(ctx). See how the diff feels. That’s the whole migration, one file at a time, and it’s genuinely lower risk than most stdlib swaps I’ve done.
And if you like this kind of “here’s the setup I actually run” writeup, my post on Go 1.23 iterators is in the same voice and might save you a similar afternoon.