{
"$type": "site.standard.document",
"canonicalUrl": "https://rednafi.com/go/typesafe-slogging/",
"description": "The default slog API is loose enough that a careless line ships broken JSON to production. Pin it down with Attr constructors, LogAttrs, a context-borne logger, and sloglint.",
"path": "/go/typesafe-slogging/",
"publishedAt": "2026-05-09T00:00:00.000Z",
"site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
"tags": [
"Go",
"Logging",
"API"
],
"textContent": "Typically on a brownfield project I don't care much about logging libraries and just go with\nwhatever's already set up. Before slog, I was an avid zap/zerolog user for years. But since\nGo 1.21, I've dropped third-party logging libraries in favor of slog. I even [recently\nranted a bit on r/golang] about people pulling in third-party libs when slog is right there.\nThe common complaints against slog are:\n\n- The typical usage pattern isn't type-safe.\n- It allocates a bit more than zap or zerolog.\n\nWorking on a fairly large-scale deployment (1000+ k8s pods serving 250rps), I haven't run\ninto a case where slog's extra allocations were the reason behind memory pressure or tail\nlatency. So going with the stdlib now is a no-brainer to me. The API isn't bad either, once\na few patterns settle. The rest of the post is the small workflow I default to. It's not a\nslog API tour. The [stdlib docs] do that better than I could, and my [earlier post on slog]\nhas the basics.\n\nThe nicest API isn't type-safe\n\nMost slog code I see reaches for the level helpers like slog.Info and slog.Warn, which\ntake (msg string, args ...any). The args are meant to alternate between keys and values,\nbut the compiler sees any and won't enforce that. Forget a value and the trailing key\nbecomes a !BADKEY:\n\nSwap a key and a value and the dashboard indexes records under the value:\n\nPass the wrong type and the field ships as the wrong JSON type:\n\nTwo of the three ship valid JSON that's wrong. Queries filtering on amount skip records\nwhere the field arrived as a string. Dashboards keyed on order_id end up indexed by user\nvalues. Nothing alerts you.\n\nPass the logger as a dependency\n\nThe package-level slog.Info, slog.Warn, and slog.Default() route through a mutable\nglobal. Anyone can swap it via slog.SetDefault, which makes parallel tests racy and leaves\nlibrary code that calls slog.Info dependent on main having set the default. Forget the\nsetup in some new entry point and you fall back to the plain-text default with no\ncompile-time hint.\n\nI pass slog.Logger in as a constructor argument:\n\nmain builds one logger and threads it through. Tests build their own writing into a\nbuffer.\n\nLogAttrs over Info\n\nOn that logger, use LogAttrs instead of Info:\n\nThe constructors pin the value type at the call site, so none of the three failures from the\nkv form compile.\n\nLogAttrs is also cheaper. Info boxes every arg into an any (heap-allocating ints and\nsmall values), walks the slice at runtime to pair keys with values, and type-switches each\nvalue to build the Attr. LogAttrs skips all that. The attrs arrive pre-typed, so the\nint64 sits in a typed field inside Value and the slice goes straight to the handler.\nBoth paths allocate the record, but Info adds N interface boxes plus a runtime parse loop\non top.\n\nThe trade-off is typing. LogAttrs is the most verbose method, and slog gives you plenty to\npick from. Info, Warn, Error, and Debug take kv pairs and no context. They fall back\nto context.Background() internally. InfoContext and friends add an explicit context.\nLog takes a context and an explicit level. LogAttrs takes the same context and level but\nswaps the kv pairs for typed attrs. Every call site asks you to pick the one that fits.\n\nDefaulting to LogAttrs everywhere trades typing for fewer decisions. No \"Info or\nInfoContext?\" question, no \"kv or typed?\" question. Every call is the same shape:\nlogger.LogAttrs(ctx, level, msg, attrs...).\n\nWhen there's no surrounding context, I pass context.TODO() instead of\ncontext.Background(). Background() is reserved for main and the composition root, so\nTODO() further down signals \"no context plumbed through yet\". If I'm reaching for TODO()\na lot, that's a prompt to ask whether the layer needs a context plumbed in or shouldn't be\nlogging at all.\n\nPush attrs into helpers\n\nLogAttrs fixes types at the call site, but slog.String(\"order_id\", id) written inline\nstill puts the same key string everywhere an order ID gets logged. Decide tomorrow that you\nwant it spelled orderID and you're grepping. Decide that emails shouldn't ship in logs and\nyou're grepping again, hoping you didn't miss a typo.\n\nI keep every attribute helper in one file inside an internal/log package:\n\nImported as applog to dodge the stdlib log collision. Every call site reads the same\nway:\n\nFor fields that always log together, push them into a single helper that returns\n[]slog.Attr:\n\nAnd spread with ... at the call site:\n\nAdd a field like currency to Order() and every order log picks it up.\n\nRenames are one-line edits in attrs.go. Types live in one place, so AmountCents(int64)\nwon't take an int from any caller.\n\nSpelling mistakes can't drift across files. Inline slog.String(\"request_id\", id) in one\nplace and slog.String(\"reqwest_id\", id) somewhere else, and you'll be wondering why half\nthe logs don't show up under request_id. With one helper per attribute, the typo lives in\none function and either every call site has it or none does.\n\nNeed to redact Email? Change Email() to return slog.String(\"email\", \"[redacted]\") and\nevery call site updates. LLMs pick up the pattern fast too. Tell an agent to log a new field\nand it adds a helper in attrs.go and calls it from the right place.\n\nBut what about nested structures?\n\nSame trick. The helper returns a group instead of a single value:\n\nEmail doesn't appear inside the group, so no caller can leak it via applog.User(u). Used\nthe same way as the rest:\n\nIn the wild\n\nThe shape shows up across plenty of production Go codebases. [syncthing] keeps its slog\nhelpers in internal/slogutil/slogvalues.go. [BloodHound] has a package literally named\nattr for them. [Teleport] does the same in lib/join/internal/diagnostic/diagnostic.go,\nwith zero-value suppression baked in. [FerretDB] exports Error(err) slog.Attr.\n\nA minimal end-to-end example is on this [Go Playground share]. It has the helpers in\ninternal/log, a Service that uses them, and a main that calls into it.\n\nEnforce it with sloglint\n\n[sloglint] enforces the workflow on every PR. The rules I default to:\n\nattr-only rejects the kv form. no-global: \"all\" blocks slog.Info and slog.Default().\ncontext: \"all\" rejects any call without a context. static-msg keeps the message a string\nliteral. key-naming-case: snake flags any key that isn't snake_case.\n\n> [!Gist]\n>\n> - Take the logger as a constructor argument. Never reach for slog.Default() or any\n> package-level slog function.\n> - Always use logger.LogAttrs(ctx, level, msg, attrs...). Not logger.Info,\n> logger.Warn, or any of the kv-flavored helpers.\n> - Every attribute comes from a helper in internal/log/attrs.go. Write\n> applog.OrderID(o.ID), never slog.String(\"order_id\", o.ID) inline.\n> - [sloglint] enforces all three on every commit so the workflow doesn't erode.\n\n\n\n\n[recently ranted a bit on r/golang]:\n https://old.reddit.com/r/golang/comments/1t2jilv/just_use_slog_itll_be_fine/\n\n[syncthing]:\n https://github.com/syncthing/syncthing/blob/main/internal/slogutil/slogvalues.go\n\n[BloodHound]:\n https://github.com/SpecterOps/BloodHound/blob/main/packages/go/bhlog/attr/attr.go\n\n[Teleport]:\n https://github.com/gravitational/teleport/blob/master/lib/join/internal/diagnostic/diagnostic.go\n\n[FerretDB]:\n https://github.com/FerretDB/FerretDB/blob/main/internal/util/logging/logging.go\n\n[sloglint]:\n https://github.com/go-simpler/sloglint\n\n[stdlib docs]:\n https://pkg.go.dev/log/slog\n\n[earlier post on slog]:\n /go/structured-logging-with-slog\n\n[Go Playground share]:\n https://go.dev/play/p/25NKrP9xxoJ",
"title": "Type-safe slogging"
}