{"id":464,"date":"2026-07-15T13:03:18","date_gmt":"2026-07-15T13:03:18","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/golang-slog-in-production-the-structured-logging-i-actually-ship\/"},"modified":"2026-07-15T13:03:18","modified_gmt":"2026-07-15T13:03:18","slug":"golang-slog-in-production-the-structured-logging-i-actually-ship","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/golang-slog-in-production-the-structured-logging-i-actually-ship\/","title":{"rendered":"Golang slog in Production: The Structured Logging I Actually Ship"},"content":{"rendered":"<p>Short version for the impatient: I ripped <code>go.uber.org\/zap<\/code> out of a mid-sized Go backend last quarter and replaced it with the stdlib <code>log\/slog<\/code>. 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.<\/p>\n<p>I resisted this for a while. <code>zap<\/code> is fast, well-known, and I had been copy-pasting the same <code>zap.NewProduction()<\/code> setup across services for years. But once <code>slog<\/code> 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 <code>slog<\/code> shape I now ship, plus the few sharp edges I hit that the official docs kind of glide over.<\/p>\n<h2 id=\"why-i-finally-stopped-reaching-for-zap\">Why I finally stopped reaching for zap<\/h2>\n<p>Honestly, <code>slog<\/code> isn&rsquo;t dramatically better than <code>zap<\/code>. In micro-benchmarks <code>zap<\/code> is still faster on hot loops, and its <code>SugaredLogger<\/code> API is nicer than <code>slog.Info(msg, \"key\", val)<\/code> for people who hate typing key-value pairs. So the case for switching isn&rsquo;t performance. The case is that <code>slog<\/code> is in the standard library, which means every dependency I pull in will eventually accept a <code>*slog.Logger<\/code> and log the way I want it to log. That&rsquo;s already happening with the newer <code>net\/http<\/code>, <code>database\/sql\/driver<\/code> wrappers I use, and a couple of internal libraries at work.<\/p>\n<p>The other thing that pushed me: when you&rsquo;re paged at 3am, the fewer moving parts under your logging pipeline, the better. <code>slog<\/code> is boring. Boring in a production log library is a compliment.<\/p>\n<p>If you want the full backstory on the design, the <a href=\"https:\/\/go.dev\/blog\/slog\" rel=\"nofollow noopener\" target=\"_blank\">Go blog&rsquo;s slog announcement<\/a> and the <a href=\"https:\/\/pkg.go.dev\/log\/slog\" rel=\"nofollow noopener\" target=\"_blank\">pkg.go.dev reference for <code>log\/slog<\/code><\/a> are the two sources that helped me most. Read them in that order.<\/p>\n<h3 id=\"the-beforeafter-that-convinced-my-team\">The before\/after that convinced my team<\/h3>\n<p>Here&rsquo;s what a request-scoped log line looked like in our <code>zap<\/code> setup:<\/p>\n<pre><code class=\"language-go\">\/\/ old: zap, sugared\nlogger.Infow(&quot;payment settled&quot;,\n    &quot;user_id&quot;, user.ID,\n    &quot;amount_cents&quot;, amt,\n    &quot;provider&quot;, &quot;stripe&quot;,\n    &quot;request_id&quot;, reqID,\n)\n<\/code><\/pre>\n<p>And here&rsquo;s the same line in <code>slog<\/code> after migration:<\/p>\n<pre><code class=\"language-go\">\/\/ new: stdlib slog\nlogger.InfoContext(ctx, &quot;payment settled&quot;,\n    slog.String(&quot;user_id&quot;, user.ID),\n    slog.Int64(&quot;amount_cents&quot;, amt),\n    slog.String(&quot;provider&quot;, &quot;stripe&quot;),\n)\n<\/code><\/pre>\n<p>Notice the <code>request_id<\/code> disappeared from the call site. That&rsquo;s not a bug, it&rsquo;s the whole point of the context-based approach I&rsquo;ll get to in a second.<\/p>\n<h2 id=\"the-one-handler-i-actually-use-in-prod\">The one handler I actually use in prod<\/h2>\n<p>I&rsquo;ve tried the fancy handlers. <code>slog-multi<\/code>, <code>tint<\/code>, custom ones that wrap OpenTelemetry. In production I use exactly one: the stdlib <code>slog.NewJSONHandler<\/code>, configured once at process start, and that&rsquo;s it.<\/p>\n<pre><code class=\"language-go\">func newLogger() *slog.Logger {\n    opts := &amp;slog.HandlerOptions{\n        Level:     slog.LevelInfo,\n        AddSource: false, \/\/ flip to true only when debugging locally\n    }\n    h := slog.NewJSONHandler(os.Stdout, opts)\n    return slog.New(h)\n}\n<\/code><\/pre>\n<p>Three things I learned the hard way here.<\/p>\n<p>First, <code>AddSource: true<\/code> 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&rsquo;re actually staring at logs on your laptop.<\/p>\n<p>Second, <code>os.Stdout<\/code> 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&rsquo;ll regret it.<\/p>\n<p>Third, set the default logger. <code>slog.SetDefault(logger)<\/code> at the top of <code>main()<\/code> means every library that uses <code>slog<\/code> package-level calls picks up your config for free. Skipping this line was my single biggest &ldquo;why aren&rsquo;t these logs formatted right&rdquo; bug in the first week.<\/p>\n<h2 id=\"context-request-ids-and-the-pattern-that-stuck\">Context, request IDs, and the pattern that stuck<\/h2>\n<p>The reason I dropped <code>zap<\/code>&rsquo;s <code>With()<\/code> chains is that <code>slog<\/code> 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.<\/p>\n<p>Here&rsquo;s the middleware I run at the edge of every HTTP handler:<\/p>\n<pre><code class=\"language-go\">func requestLogger(base *slog.Logger) func(http.Handler) http.Handler {\n    return func(next http.Handler) http.Handler {\n        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n            reqID := r.Header.Get(&quot;X-Request-ID&quot;)\n            if reqID == &quot;&quot; {\n                reqID = randomID()\n            }\n            l := base.With(\n                slog.String(&quot;request_id&quot;, reqID),\n                slog.String(&quot;path&quot;, r.URL.Path),\n                slog.String(&quot;method&quot;, r.Method),\n            )\n            ctx := context.WithValue(r.Context(), loggerKey{}, l)\n            next.ServeHTTP(w, r.WithContext(ctx))\n        })\n    }\n}\n\nfunc fromContext(ctx context.Context) *slog.Logger {\n    if l, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok {\n        return l\n    }\n    return slog.Default()\n}\n<\/code><\/pre>\n<p>Downstream, any handler grabs the logger like this:<\/p>\n<pre><code class=\"language-go\">func handleCharge(w http.ResponseWriter, r *http.Request) {\n    log := fromContext(r.Context())\n    log.InfoContext(r.Context(), &quot;charge started&quot;, slog.Int64(&quot;amount_cents&quot;, amt))\n    \/\/ ...\n}\n<\/code><\/pre>\n<p>One thing to be honest about: this isn&rsquo;t a <code>slog<\/code>-only pattern. You could do the same with <code>zap<\/code>. But the reason it feels less awkward now is that <code>InfoContext<\/code>, <code>WarnContext<\/code>, and friends were added specifically so the handler can also inspect the context. If you use OpenTelemetry, that&rsquo;s how trace IDs get pulled in automatically by a wrapping handler. I don&rsquo;t currently pipe traces into logs, but if I did, I&rsquo;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.<\/p>\n<p>My <a href=\"https:\/\/abrarqasim.com\/blog\/golang-concurrency-patterns-2026-errgroup-context-i-actually-ship\" rel=\"noopener\">Go concurrency patterns writeup<\/a> covers the sister pattern for propagating context through goroutines. If you&rsquo;re doing structured logging without also passing <code>ctx.Done()<\/code> down into your workers, you&rsquo;re going to lose logs on shutdown.<\/p>\n<h2 id=\"groups-and-with-how-i-keep-noise-out-of-prod-logs\">Groups and With: how I keep noise out of prod logs<\/h2>\n<p>The two <code>slog<\/code> features I use every day are <code>With<\/code> (for shared attrs) and groups (for namespacing). Grouping matters more than it looks.<\/p>\n<p>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:<\/p>\n<pre><code class=\"language-go\">log.InfoContext(ctx, &quot;charge settled&quot;,\n    slog.Group(&quot;db&quot;,\n        slog.Int(&quot;rows&quot;, 3),\n        slog.Duration(&quot;latency&quot;, dbLatency),\n    ),\n    slog.Group(&quot;stripe&quot;,\n        slog.String(&quot;charge_id&quot;, chargeID),\n        slog.Int(&quot;http_status&quot;, 200),\n    ),\n    slog.Group(&quot;fraud&quot;,\n        slog.Float64(&quot;score&quot;, 0.12),\n    ),\n)\n<\/code><\/pre>\n<p>Which lands in the JSON output as nested objects. In Datadog, Grafana Loki, or whatever you&rsquo;re using, this is a lot easier to query than a flat forest of keys. I wish I&rsquo;d started with groups from day one instead of retrofitting them later.<\/p>\n<p>The other thing I do heavily is <code>logger.With<\/code> at package init for library-style code:<\/p>\n<pre><code class=\"language-go\">var log = slog.Default().With(slog.String(&quot;component&quot;, &quot;billing&quot;))\n<\/code><\/pre>\n<p>That one line, at the top of each internal package, means every log line from that package is instantly filterable by <code>component<\/code>. It&rsquo;s the same trick people use with prefixed loggers in other languages, only cleaner because <code>With<\/code> returns a new logger and doesn&rsquo;t mutate global state.<\/p>\n<h2 id=\"levels-sampling-and-the-hot-path-problem\">Levels, sampling, and the hot-path problem<\/h2>\n<p>Here&rsquo;s where I was annoyed with <code>slog<\/code> for about a week. It doesn&rsquo;t ship with sampling. <code>zap<\/code> has a very nice <code>sampler.Core<\/code> that lets you say &ldquo;emit the first 100 of each log line per second, then drop&rdquo;. When you&rsquo;re running a request-per-millisecond service and a downstream starts throwing errors, <code>zap<\/code> sampling saves your log bill. <code>slog<\/code> makes you build that yourself.<\/p>\n<p>The workaround I use is a small wrapping handler with a per-message counter:<\/p>\n<pre><code class=\"language-go\">type samplingHandler struct {\n    inner slog.Handler\n    counts sync.Map \/\/ map[string]*atomic.Int64\n    every  int64\n}\n\nfunc (h *samplingHandler) Handle(ctx context.Context, r slog.Record) error {\n    v, _ := h.counts.LoadOrStore(r.Message, new(atomic.Int64))\n    n := v.(*atomic.Int64).Add(1)\n    if n%h.every != 0 {\n        return nil\n    }\n    return h.inner.Handle(ctx, r)\n}\n\/\/ Enabled, WithAttrs, WithGroup omitted for brevity: delegate to inner.\n<\/code><\/pre>\n<p>Not as clever as <code>zap<\/code>&rsquo;s tick-based sampler, but it&rsquo;s fifty lines and I understand every one of them. For the noisier services I&rsquo;ve since switched to letting the ingest side (Vector, in our case) do the sampling, which is where I probably should&rsquo;ve been doing it all along.<\/p>\n<p>If you want a longer, less opinionated look at handler internals, the <a href=\"https:\/\/github.com\/golang\/example\/blob\/master\/slog-handler-guide\/README.md\" rel=\"nofollow noopener\" target=\"_blank\">Go team&rsquo;s guide to writing a handler<\/a> is the closest thing to a canonical reference.<\/p>\n<h2 id=\"what-i-still-miss-from-zap\">What I still miss from zap<\/h2>\n<p>Two things.<\/p>\n<p>One, the <code>Sugar()<\/code> API is genuinely nicer for exploratory code. <code>sugar.Infow(\"msg\", \"k\", v)<\/code> reads better than <code>slog.Info(\"msg\", slog.Any(\"k\", v))<\/code>. <code>slog<\/code>&rsquo;s key-value variadic form (<code>slog.Info(\"msg\", \"k\", v)<\/code>) exists but the type safety is weaker and you lose autocomplete. I now use <code>slog.String<\/code>, <code>slog.Int64<\/code>, and friends religiously, but it feels a hair more ceremonial than the zap version.<\/p>\n<p>Two, <code>zap<\/code> has better first-party integrations with a lot of the observability vendors. Datadog&rsquo;s Go SDK, for example, still assumes <code>zap<\/code> in most examples. That&rsquo;ll fix itself over the next year, but if you&rsquo;re setting up a brand new service today, budget an afternoon to figure out the right handler for your log backend.<\/p>\n<p>Also: I still keep <code>zap<\/code> in my monorepo for one specific service, our real-time market data feed, where the extra 30% throughput matters. Sometimes the right answer is &ldquo;use both&rdquo;. I write about that kind of pragmatic trade-off in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">the work I put on my portfolio<\/a>, because pretending every service should look identical is how you end up with pain later.<\/p>\n<h2 id=\"try-this-next-week\">Try this next week<\/h2>\n<p>Grab whichever Go service in your stack has the most tangled log call sites. Add <code>slog.SetDefault(newLogger())<\/code> in <code>main<\/code>. Delete one <code>zap.Logger<\/code> field from a struct and replace it with a call to <code>fromContext(ctx)<\/code>. See how the diff feels. That&rsquo;s the whole migration, one file at a time, and it&rsquo;s genuinely lower risk than most stdlib swaps I&rsquo;ve done.<\/p>\n<p>And if you like this kind of &ldquo;here&rsquo;s the setup I actually run&rdquo; writeup, <a href=\"https:\/\/abrarqasim.com\/blog\/go-1-23-iterators-hand-rolled-boilerplate-i-stopped-writing\" rel=\"noopener\">my post on Go 1.23 iterators<\/a> is in the same voice and might save you a similar afternoon.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>How I moved a Go backend from zap to the stdlib log\/slog package, the handler config I use in prod, and what I quietly miss from the old logger.<\/p>\n","protected":false},"author":2,"featured_media":463,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"How I moved a Go backend from zap to the stdlib log\/slog package, the handler config I use in prod, and what I quietly miss from the old logger.","rank_math_focus_keyword":"golang slog","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,159],"tags":[49,46,47,512,328,329,326,515,513,514],"class_list":["post-464","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-go","tag-backend","tag-go","tag-golang","tag-log-slog","tag-logging","tag-observability","tag-slog","tag-stdlib","tag-structured-logging-2","tag-zap"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/464","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=464"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/464\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/463"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=464"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=464"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=464"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}