skip to content

What does slog's `Logger.With` save at log time compared with repeating those attributes per call?

level: middleimportance: nice to knowfreq 28%

answer

  1. some attributes never change
  2. the handler can do work once
  3. WithAttrs returns a derived handler
  4. pre-formatted bytes copied into each line
  5. build it outside the loop

basics

~20 s

With returns a logger whose handler already holds those attributes. The built-in text and JSON handlers serialise them once, then copy the finished bytes into every record, so those keys and values are never formatted again.

solid answer

~40 s

`Logger.With(args ...any)` does not mutate anything; it calls the handler's `WithAttrs([]slog.Attr)` once and wraps the derived handler in a new `*slog.Logger`. The built-in text and JSON handlers use that moment to pre-format the attributes into a buffer they keep, and each later record simply copies those already-escaped bytes into its output. So the boxing, the key/value decoding and the JSON escaping for `stage`, `shard` or `service` happen once at construction rather than on every one of the next million lines. The corollary matters as much as the saving: build the derived logger once, at start-up or per request, and never inside the per-record loop — calling `With` per record allocates a fresh logger and handler each time and is strictly worse than passing the fields inline.

code

go · 7 lines
go
// Once, when the stage starts: these two are serialised by the handler now.
stage := base.With(slog.String("stage", "enrich"), slog.Int("shard", shardID))

for rec := range in {
	// Per record: only the varying attribute has to be rendered.
	stage.LogAttrs(ctx, slog.LevelInfo, "enriched", slog.Int("bytes", len(rec.Payload)))
}

go deeper

for a junior

Know that Logger.With returns a new logger carrying extra fields and leaves the original alone, and that you build it once rather than on every log call.

for a middle

Explain the mechanism: With calls the handler's WithAttrs, and the built-in text and JSON handlers serialise those attributes then, copying the finished bytes into each later record instead of formatting them again.

for a senior

Show where the derived logger is created and why — per process, per stage, per request — and be able to say that the pre-formatting is a property of the standard handlers rather than a guarantee of the Handler interface.

for a principal

Own which constant fields belong on every line at all. Bound context is cheap to produce but not free to carry, and a service-wide convention for what every record must include is a decision that outlives any one hot path.

## What With actually does ```go stage := base.With(slog.String("stage", "enrich"), slog.Int("shard", shardID)) ``` `func (l *Logger) With(args ...any) *slog.Logger` returns a **new** logger; the receiver is untouched. Internally it turns the arguments into `[]slog.Attr` and calls the handler's `WithAttrs(attrs []Attr) Handler`, which returns a derived handler that will include those attributes in every record it handles. The new logger wraps that derived handler. Note that `With` accepts the same alternating key/value convention as `Info`, and as a special case it also accepts a ready-made `slog.Attr` as a single argument — which is why the call above can pass typed constructors directly. ## Where the saving comes from The interesting part is what the built-in handlers do inside `WithAttrs`. `slog.TextHandler` and `slog.JSONHandler` do not merely stash the slice: they **serialise those attributes immediately** into a buffer the derived handler owns. From then on, every record the derived logger emits gets those bytes copied verbatim into its output. So for a field that never changes across a stage's lifetime, the whole chain of work happens exactly once: - converting the value out of an interface; - deciding how to render its kind; - escaping the key and the value for the output format; - appending the separators. If instead you pass `"stage", "enrich", "shard", shardID` on every call, all of that happens per record — boxing at the call site, decoding into attributes, and formatting inside `Handle` — for values that were identical the last hundred thousand times. ## The contract does not promise it This is a property of the standard handlers, not of the `slog.Handler` interface. `WithAttrs` is only required to return a handler whose records include the given attributes; how it does that is up to the implementation, and a handler you did not write may simply keep the slice and format it per record. If the saving is load-bearing for you, benchmark the handler you actually run rather than assuming the optimisation exists. ## The anti-pattern Because `With` allocates — a new `Logger`, a derived handler, and the pre-formatted buffer — the win depends completely on **how often you call it relative to how often you log through the result**. Called once per process, per stage or per request, it amortises across every line that follows. Called inside the loop: ```go for rec := range in { logger.With("stage", "enrich").Info("enriched") // worse than passing the field inline } ``` you now pay for building a logger *and* formatting the attribute on every record, which is more work than you were trying to avoid. The rule of thumb is simple: `With` belongs on a path that runs once and produces a value you hold on to. ## What it does not save The pre-formatted bytes are written on **every** line the derived logger emits. `With` makes the fields cheap to produce, not free to carry: a logger carrying eight constant attributes still writes those eight fields on every record. If a field is genuinely constant for the whole process and every consumer already knows it, the cheapest attribute is the one you do not add — but that is a decision about what the line should say, not about `With`'s mechanics. ## A useful mental model Think of `With` as **partial application for a log line**. You bind the arguments that will not change, the handler compiles them into their output form once, and each later call supplies only the parts that vary. That is why the pairing with `LogAttrs` is so natural: `With` removes the constant fields from the hot path entirely, and `LogAttrs` makes the few remaining varying fields cheap to pass.

  • Is the pre-formatting guaranteed by the slog.Handler interface?
    No. `WithAttrs` only has to return a handler whose later records include those attributes; serialising them immediately is a choice the built-in text and JSON handlers make. Another handler may keep the slice and render it per record, in which case `With` saves the call-site boxing but nothing inside `Handle`. Benchmark the handler you actually run.
  • What does a With-derived logger cost beyond the one-time formatting?
    Each `With` allocates a new `*slog.Logger` and a derived handler holding the pre-formatted buffer, so it is only a win when many lines flow through the result. And the bound attributes are written on every one of those lines — `With` makes them cheap to produce, not free to carry.
  • Can you call With per request in an HTTP-style service rather than per process?
    Yes, and that is the usual shape: bind a request id or a route once when the request starts, then log through the derived logger for the rest of the handler. The amortisation argument is the same — one construction against many lines — just at a shorter lifetime.

It is partial application for a log line: you bind the arguments that never change, the handler renders them once, and each later call supplies only the parts that vary.

saying these in an interview costs you the question

  • Calls Logger.With inside the per-record loop
  • Thinks With mutates the logger it is called on
  • Assumes every handler pre-formats what WithAttrs receives
  • Believes With attributes are written only once, not per line
  • Cannot say what WithAttrs returns