Type-safe slogging
Table of contents
Typically on a brownfield project I don’t care much about logging libraries and just go with whatever’s already set up. Before slog, I was an avid zap/zerolog user for years. But since Go 1.21, I’ve dropped third-party logging libraries in favor of slog. I even recently ranted a bit on r/golang about people pulling in third-party libs when slog is right there. The common complaints against slog are:
- The typical usage pattern isn’t type-safe.
- It allocates a bit more than zap or zerolog.
Working on a fairly large-scale deployment (1000+ k8s pods serving 250rps), I haven’t run into a case where slog’s extra allocations were the reason behind memory pressure or tail latency. So going with the stdlib now is a no-brainer to me. The API isn’t bad either, once a few patterns settle. The rest of the post is the small workflow I default to. It’s not a slog API tour. The stdlib docs do that better than I could, and my earlier post on slog has the basics.
The nicest API isn’t type-safe #
Most slog code I see reaches for the level helpers like slog.Info and slog.Warn, which
take (msg string, args ...any). The args are meant to alternate between keys and values,
but the compiler sees any and won’t enforce that. Forget a value and the trailing key
becomes a !BADKEY:
slog.Info("placed", "order_id", id, "amount")
// {"msg":"placed","order_id":"ord_001","!BADKEY":"amount"}
Swap a key and a value and the dashboard indexes records under the value:
slog.Info("placed", id, "order_id")
// {"msg":"placed","ord_001":"order_id"}
Pass the wrong type and the field ships as the wrong JSON type:
slog.Info("placed", "amount", "1290")
// {"msg":"placed","amount":"1290"} // string, not int
Two of the three ship valid JSON that’s wrong. Queries filtering on amount skip records
where the field arrived as a string. Dashboards keyed on order_id end up indexed by user
values. Nothing alerts you.
Pass the logger as a dependency #
The package-level slog.Info, slog.Warn, and slog.Default() route through a mutable
global. Anyone can swap it via slog.SetDefault, which makes parallel tests racy and leaves
library code that calls slog.Info dependent on main having set the default. Forget the
setup in some new entry point and you fall back to the plain-text default with no
compile-time hint.
I pass *slog.Logger in as a constructor argument:
type Service struct {
logger *slog.Logger
}
func NewService(logger *slog.Logger) *Service {
return &Service{logger: logger}
}
main builds one logger and threads it through. Tests build their own writing into a
buffer.
LogAttrs over Info #
On that logger, use LogAttrs instead of Info:
logger.LogAttrs(ctx, slog.LevelInfo, "placed",
slog.String("order_id", id),
slog.Int64("amount", amount),
)
The constructors pin the value type at the call site, so none of the three failures from the kv form compile.
LogAttrs is also cheaper. Info boxes every arg into an any (heap-allocating ints and
small values), walks the slice at runtime to pair keys with values, and type-switches each
value to build the Attr. LogAttrs skips all that. The attrs arrive pre-typed, so the
int64 sits in a typed field inside Value and the slice goes straight to the handler.
Both paths allocate the record, but Info adds N interface boxes plus a runtime parse loop
on top.
The trade-off is typing. LogAttrs is the most verbose method, and slog gives you plenty to
pick from. Info, Warn, Error, and Debug take kv pairs and no context. They fall back
to context.Background() internally. InfoContext and friends add an explicit context.
Log takes a context and an explicit level. LogAttrs takes the same context and level but
swaps the kv pairs for typed attrs. Every call site asks you to pick the one that fits.
Defaulting to LogAttrs everywhere trades typing for fewer decisions. No “Info or
InfoContext?” question, no “kv or typed?” question. Every call is the same shape:
logger.LogAttrs(ctx, level, msg, attrs...).
When there’s no surrounding context, I pass context.TODO() instead of
context.Background(). Background() is reserved for main and the composition root, so
TODO() further down signals “no context plumbed through yet”. If I’m reaching for TODO()
a lot, that’s a prompt to ask whether the layer needs a context plumbed in or shouldn’t be
logging at all.
Push attrs into helpers #
LogAttrs fixes types at the call site, but slog.String("order_id", id) written inline
still puts the same key string everywhere an order ID gets logged. Decide tomorrow that you
want it spelled orderID and you’re grepping. Decide that emails shouldn’t ship in logs and
you’re grepping again, hoping you didn’t miss a typo.
I keep every attribute helper in one file inside an internal/log package:
// internal/log/attrs.go
package log
import "log/slog"
func OrderID(s string) slog.Attr { return slog.String("order_id", s) }
func UserID(s string) slog.Attr { return slog.String("user_id", s) }
func AmountCents(c int64) slog.Attr { return slog.Int64("amount_cents", c) }
func Err(e error) slog.Attr { return slog.String("err", e.Error()) }
Imported as applog to dodge the stdlib log collision. Every call site reads the same
way:
logger.LogAttrs(ctx, slog.LevelInfo, "placed order",
applog.OrderID(o.ID),
applog.UserID(o.UserID),
applog.AmountCents(o.AmountCents),
)
For fields that always log together, push them into a single helper that returns
[]slog.Attr:
type Order struct {
ID string
UserID string
AmountCents int64
}
// internal/log/attrs.go
func Order(o Order) []slog.Attr {
return []slog.Attr{
OrderID(o.ID),
UserID(o.UserID),
AmountCents(o.AmountCents),
}
}
And spread with ... at the call site:
logger.LogAttrs(ctx, slog.LevelInfo, "placed order", applog.Order(o)...)
Add a field like currency to Order() and every order log picks it up.
Renames are one-line edits in attrs.go. Types live in one place, so AmountCents(int64)
won’t take an int from any caller.
Spelling mistakes can’t drift across files. Inline slog.String("request_id", id) in one
place and slog.String("reqwest_id", id) somewhere else, and you’ll be wondering why half
the logs don’t show up under request_id. With one helper per attribute, the typo lives in
one function and either every call site has it or none does.
Need to redact Email? Change Email() to return slog.String("email", "[redacted]") and
every call site updates. LLMs pick up the pattern fast too. Tell an agent to log a new field
and it adds a helper in attrs.go and calls it from the right place.
But what about nested structures? #
Same trick. The helper returns a group instead of a single value:
type User struct {
ID string
Name string
Email string
Tier string
}
// internal/log/attrs.go
func User(u User) slog.Attr {
return slog.Group("user",
slog.String("id", u.ID),
slog.String("tier", u.Tier),
)
}
Email doesn’t appear inside the group, so no caller can leak it via applog.User(u). Used
the same way as the rest:
logger.LogAttrs(ctx, slog.LevelInfo, "user signed in",
applog.User(u),
)
{"msg":"user signed in","user":{"id":"u_42","tier":"gold"}}
In the wild #
The shape shows up across plenty of production Go codebases. syncthing
keeps its slog
helpers in internal/slogutil/slogvalues.go. BloodHound
has a package literally named
attr for them. Teleport
does the same in lib/join/internal/diagnostic/diagnostic.go,
with zero-value suppression baked in. FerretDB
exports Error(err) slog.Attr.
A minimal end-to-end example is on this Go Playground share
. It has the helpers in
internal/log, a Service that uses them, and a main that calls into it.
Enforce it with sloglint #
sloglint enforces the workflow on every PR. The rules I default to:
linters-settings:
sloglint:
attr-only: true
no-global: "all"
context: "all"
static-msg: true
key-naming-case: snake
attr-only rejects the kv form. no-global: "all" blocks slog.Info and slog.Default().
context: "all" rejects any call without a context. static-msg keeps the message a string
literal. key-naming-case: snake flags any key that isn’t snake_case.
Gist
- Take the logger as a constructor argument. Never reach for
slog.Default()or any package-level slog function. - Always use
logger.LogAttrs(ctx, level, msg, attrs...). Notlogger.Info,logger.Warn, or any of the kv-flavored helpers. - Every attribute comes from a helper in
internal/log/attrs.go. Writeapplog.OrderID(o.ID), neverslog.String("order_id", o.ID)inline. - sloglint enforces all three on every commit so the workflow doesn’t erode.