{"id":282,"date":"2026-05-26T13:04:32","date_gmt":"2026-05-26T13:04:32","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/go-slog-structured-logging-i-stopped-hand-rolling\/"},"modified":"2026-05-26T13:04:32","modified_gmt":"2026-05-26T13:04:32","slug":"go-slog-structured-logging-i-stopped-hand-rolling","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/go-slog-structured-logging-i-stopped-hand-rolling\/","title":{"rendered":"Go slog: the Structured Logging I Stopped Hand-Rolling"},"content":{"rendered":"<p>For years my Go logging setup was the same handful of steps on every new service. Import a third-party logger, wire it to emit JSON, then spend twenty minutes deciding how to thread it through the code so handlers could actually reach it. I had a little internal package called <code>logx<\/code> that I copied between projects. It wasn&rsquo;t good. It was just mine, and it was familiar, and familiar is a hard thing to give up.<\/p>\n<p>Go 1.21 added <a href=\"https:\/\/pkg.go.dev\/log\/slog\" rel=\"nofollow noopener\" target=\"_blank\"><code>log\/slog<\/code><\/a> to the standard library, and after avoiding it for a while out of pure habit, I deleted <code>logx<\/code>. Not refactored. Deleted. The standard library now does the part I actually cared about, which is structured logs with typed key and value pairs, and it does it well enough that reaching for an external logger needs a real justification now.<\/p>\n<p>Here&rsquo;s what <code>slog<\/code> replaced for me, where it&rsquo;s genuinely good, and the one part I still find awkward.<\/p>\n<h2 id=\"what-was-wrong-with-plain-log\">What was wrong with plain log<\/h2>\n<p>Go always shipped a <code>log<\/code> package, and it was fine for exactly one job: printing a line of text so a human would see it. The trouble is that text is where log data goes to die. Here&rsquo;s the kind of line my old code produced:<\/p>\n<pre><code class=\"language-go\">log.Printf(&quot;user %d updated order %d, status now %s&quot;, userID, orderID, status)\n<\/code><\/pre>\n<p>You can read that. A machine cannot, not reliably. If you want every log line for order 4821, you&rsquo;re writing a regular expression against an English sentence. If you ship those logs to anything that indexes fields, the fields don&rsquo;t exist. There&rsquo;s just prose.<\/p>\n<p>The standard <code>log<\/code> package also has no notion of severity. Every line is just a line. If you wanted to separate routine information from a genuine warning, you built that distinction yourself, usually with a second logger and a naming convention you&rsquo;d forget within a month. So my <code>logx<\/code> package wasn&rsquo;t solving an exotic problem. It was patching two holes that the standard library left open for a decade.<\/p>\n<p>Structured logging fixes this by making each log line a set of key and value pairs instead of a sentence. The message is one field. The user id is another. The order id is another, stored as an actual number. Your log backend can then filter on <code>order_id<\/code> the way a database filters on a column. That&rsquo;s the entire pitch, and it&rsquo;s why the <a href=\"https:\/\/go.dev\/blog\/slog\" rel=\"nofollow noopener\" target=\"_blank\">Go team wrote up structured logging<\/a> as a first-class concern rather than leaving it to the ecosystem to solve five different ways.<\/p>\n<h2 id=\"slog-the-version-i-actually-use\">slog, the version I actually use<\/h2>\n<p>Here&rsquo;s that same line with <code>slog<\/code>:<\/p>\n<pre><code class=\"language-go\">slog.Info(&quot;order updated&quot;,\n    &quot;user_id&quot;, userID,\n    &quot;order_id&quot;, orderID,\n    &quot;status&quot;, status,\n)\n<\/code><\/pre>\n<p>The first argument is the message, a constant string you can group on. Everything after it is key and value pairs. Finding one order is now a field match instead of a guess.<\/p>\n<p>By default <code>slog<\/code> prints a readable line, which is what you want at your desk. In production I switch the handler to JSON once, at startup:<\/p>\n<pre><code class=\"language-go\">func main() {\n    handler := slog.NewJSONHandler(os.Stdout, &amp;slog.HandlerOptions{\n        Level: slog.LevelInfo,\n    })\n    slog.SetDefault(slog.New(handler))\n\n    slog.Info(&quot;service started&quot;, &quot;port&quot;, 8080)\n}\n<\/code><\/pre>\n<p><code>slog.SetDefault<\/code> installs a package-level logger, so the bare <code>slog.Info<\/code> calls scattered through the code pick it up automatically. That one detail is why I stopped threading a logger through every function signature. For a small service the default logger is genuinely fine, and I say that as someone who spent years insisting it wasn&rsquo;t.<\/p>\n<p>Levels come built in, <code>Debug<\/code> through <code>Error<\/code>, and the <code>HandlerOptions.Level<\/code> in that snippet is what filters them. Set it to <code>LevelInfo<\/code> in production and your <code>Debug<\/code> lines cost almost nothing, because the handler drops them before it formats anything. There&rsquo;s also a context-aware variant, <code>slog.InfoContext<\/code>, that threads a <code>context.Context<\/code> into the handler. Write a handler that reads a request id off the context once, and every call site stops needing to remember to attach it.<\/p>\n<p>When a section of code should tag every line with the same context, <code>slog.With<\/code> returns a child logger that carries those fields for you:<\/p>\n<pre><code class=\"language-go\">reqLog := slog.With(&quot;request_id&quot;, reqID, &quot;route&quot;, r.URL.Path)\nreqLog.Info(&quot;handling request&quot;)\nreqLog.Warn(&quot;slow query&quot;, &quot;ms&quot;, elapsed)\n<\/code><\/pre>\n<p>Both of those lines come out carrying <code>request_id<\/code> and <code>route<\/code> without you repeating them. That&rsquo;s the feature I lean on most, because request-scoped context is exactly the thing you forget to attach by hand.<\/p>\n<h2 id=\"where-slog-is-genuinely-good\">Where slog is genuinely good<\/h2>\n<p>Two things make <code>slog<\/code> worth the switch beyond the obvious win of structured output.<\/p>\n<p>The first is that it lives in the standard library, so it has no version of its own to track and no dependency to audit. A logging library that every service imports is a library you have to keep patched across every service. <code>slog<\/code> ships with the Go you already installed. The <a href=\"https:\/\/go.dev\/doc\/go1.21\" rel=\"nofollow noopener\" target=\"_blank\">Go 1.21 release notes<\/a> introduced it, and it has been stable since.<\/p>\n<p>The second is the handler interface. <code>slog<\/code> separates the front end, the <code>Info<\/code> and <code>Warn<\/code> calls you write, from the back end, the handler that decides what a log line becomes. The standard library gives you a text handler and a JSON handler out of the box. If you need something else, you implement one interface, and every <code>slog<\/code> call in your program and in your dependencies routes through it. That means a library logging with <code>slog<\/code> can be told how to log by the application importing it, which is the coordination the ecosystem never quite managed before.<\/p>\n<p>If you&rsquo;re structuring a service from scratch, this pairs well with how I think about Go services in general, which I went into in a <a href=\"https:\/\/abrarqasim.com\/blog\/go-web-frameworks-2026-what-i-actually-reach-for\" rel=\"noopener\">post on Go web frameworks<\/a>. Logging is one of the few decisions you want to make early and then stop thinking about.<\/p>\n<h2 id=\"the-part-i-still-find-awkward\">The part I still find awkward<\/h2>\n<p><code>slog<\/code> isn&rsquo;t perfect, and the rough edge shows up quickly: the key and value pairs are loosely typed in the form most people use.<\/p>\n<p>When you write <code>slog.Info(\"msg\", \"user_id\", userID)<\/code>, nothing checks that a value follows every key. If you drop one, <code>slog<\/code> does its best and emits a line flagged as having a bad argument, at runtime, not at compile time. I&rsquo;ve shipped that mistake. It&rsquo;s the kind of typo a stricter API would have refused outright.<\/p>\n<p>The Go team is aware of this. <code>go vet<\/code> ships an <code>slog<\/code> analyzer that catches some mismatched key and value pairs before they reach production, and it&rsquo;s worth keeping in your build. But it doesn&rsquo;t catch everything, and a <code>vet<\/code> warning is still a step you can ignore, not a wall the compiler puts in front of you.<\/p>\n<p><code>slog<\/code> does offer a stricter form through its <code>Attr<\/code> constructors:<\/p>\n<pre><code class=\"language-go\">slog.Info(&quot;order updated&quot;,\n    slog.Int(&quot;order_id&quot;, orderID),\n    slog.String(&quot;status&quot;, status),\n)\n<\/code><\/pre>\n<p>This version is much harder to get wrong, because <code>slog.Int<\/code> won&rsquo;t accept a string. It&rsquo;s also noisier to type, and most code I read in the wild uses the loose form regardless. I&rsquo;ve settled on a rule: loose form for ordinary logs, <code>Attr<\/code> constructors in the paths I care about most, anything touching money or auth. It&rsquo;s a compromise, and I&rsquo;d rather the compiler simply helped, which is a complaint I have about a few corners of Go and have made before in a <a href=\"https:\/\/abrarqasim.com\/blog\/go-error-handling-patterns-i-actually-use-2026\" rel=\"noopener\">post on Go error handling<\/a>.<\/p>\n<h2 id=\"where-to-start\">Where to start<\/h2>\n<p>If you&rsquo;re starting a new Go service in 2026, use <code>slog<\/code>. There&rsquo;s no reason to add a logging dependency before you&rsquo;ve hit a concrete need the standard library can&rsquo;t meet, and for most services that need never arrives.<\/p>\n<p>If you have an existing service on an external logger, don&rsquo;t tear it out for sport. But the next time you&rsquo;re already in the logging code for some other reason, that&rsquo;s your opening to switch.<\/p>\n<p>Here&rsquo;s the thing to try this week. Find the ugliest <code>log.Printf<\/code> in your codebase, the one with four values crammed into a sentence, and rewrite just that line as a <code>slog.Info<\/code> call with real keys. Send it to wherever your logs end up and try to filter on one of the fields. That single query, the one you could not run before, is the whole argument for structured logging, and it lands harder than anything I can write here. If you want to see the kind of Go services I build this into, that&rsquo;s over on my <a href=\"https:\/\/abrarqasim.com\/about\" rel=\"noopener\">portfolio<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Go 1.21&#8217;s log\/slog brought structured logging into the standard library. Here&#8217;s what it replaced for me, where it shines, and the one part I still find awkward.<\/p>\n","protected":false},"author":2,"featured_media":281,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Go 1.21's log\/slog brought structured logging into the standard library. Here's what it replaced for me, where it shines, and the one part I still find awkward.","rank_math_focus_keyword":"golang slog","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[159,45],"tags":[49,46,47,328,329,326,327],"class_list":["post-282","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-go","category-programming","tag-backend","tag-go","tag-golang","tag-logging","tag-observability","tag-slog","tag-structured-logging"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/282","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=282"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/282\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/281"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=282"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=282"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=282"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}