skip to content

Why does slog's `LogAttrs` allocate less than `Logger.Info` with key/value arguments?

level: middleimportance: should knowfreq 42%

answer

  1. one form is typed, the other is any
  2. converting to any can allocate
  3. Attr holds a Value, not an interface
  4. no key/value decoding at run time
  5. benchmem prints allocs/op

basics

~20 s

LogAttrs takes typed slog.Attr values, so each string, number or duration travels inside an Attr without becoming an interface. The variadic form takes any, which boxes each value on the heap and makes slog re-derive the pairs at run time.

solid answer

~50 s

`Logger.Info(msg string, args ...any)` builds a `[]any` at the call site and converts every key and value to an interface, which heap-allocates each non-pointer value; slog then walks that slice pairwise, checks that each key is a string, and turns each pair into a `slog.Attr`. `Logger.LogAttrs(ctx, level, msg, attrs ...slog.Attr)` skips both halves of that. An `Attr` is a struct of a `string` key and a `slog.Value`, and `Value` stores strings, integers, booleans and durations in its own fields rather than in an `any`, so the typed constructors `slog.String`, `slog.Int` and friends do no boxing. The record also keeps a small number of attributes inline before it needs a heap slice. With the built-in handlers the difference is usually a handful of allocations per call versus zero or one — measure it with `go test -bench=. -benchmem` and compare `allocs/op`.

code

go · 17 lines
go
func BenchmarkVariadic(b *testing.B) {
	logger := slog.New(slog.NewJSONHandler(io.Discard, nil))
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		logger.Info("stage done", "shard", 7, "records", 1024)
	}
}

func BenchmarkLogAttrs(b *testing.B) {
	logger := slog.New(slog.NewJSONHandler(io.Discard, nil))
	ctx := context.Background()
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		logger.LogAttrs(ctx, slog.LevelInfo, "stage done",
			slog.Int("shard", 7), slog.Int("records", 1024))
	}
}

go deeper

for a junior

Know that slog offers two call forms and that the typed one exists for hot paths. Recognising slog.String and slog.Int as attribute constructors, and LogAttrs as the method that takes them, is the expectation here.

for a middle

Explain the mechanism: a ...any parameter boxes each value into an interface and slog must decode the alternating key/value pairs, while an Attr carries a typed Value with neither cost. Name the record's small inline attribute array if you know it.

for a senior

Decide where the rewrite is worth its verbosity, and prove it. Point two benchmarks at the same handler writing to io.Discard and compare allocs/op rather than asserting a number from memory.

for a principal

Set the boundary for the codebase: which packages are hot enough to justify the typed form, whether a library you publish should use it so callers do not pay for your convenience, and how to stop a micro-optimisation becoming a style rule.

## Two ways to say the same thing `log/slog` offers each line in two forms: ```go logger.Info("stage done", "shard", 7, "records", 1024) logger.LogAttrs(ctx, slog.LevelInfo, "stage done", slog.Int("shard", 7), slog.Int("records", 1024)) ``` They produce the same output. They do not cost the same, and the reason is entirely in the two signatures. ## What the variadic form costs `func (l *Logger) Info(msg string, args ...any)` has a `...any` parameter. At the call site the compiler builds a `[]any` and converts each argument into an interface value. An interface value is two words — a type descriptor and a data pointer — so a value that is not already pointer-shaped needs somewhere for that pointer to point. When the compiler cannot prove the value's lifetime ends in the frame, it allocates on the heap. Because `args` is handed to slog's internals, it generally does escape. So you pay for the slice, plus roughly one allocation for each non-pointer value: the `7`, the `1024`, and each key's string header. There is a second, smaller cost on the slog side. The alternating key/value convention has to be *decoded*: slog walks the slice two elements at a time, type-asserts the first of each pair to a `string`, and builds an `Attr` from it. That work is proportional to the number of arguments, and it happens on every record. ## What LogAttrs avoids `func (l *Logger) LogAttrs(ctx context.Context, level Level, msg string, attrs ...Attr)` takes a concrete type. A `slog.Attr` is a struct holding a `string` key and a `slog.Value`. `Value` is a small struct designed to carry the common kinds — string, int64, uint64, float64, bool, `time.Duration`, `time.Time` — in its own fields rather than in an `any`. So `slog.Int("shard", 7)` produces a value with no interface conversion and therefore no boxing allocation, and `slog.String("stage", name)` carries the string without heap-allocating a copy. The pairs are already formed, so there is no run-time decoding of a key/value convention and no chance of a mismatched dangling key. And `slog.Record` keeps a small number of attributes in an inline array before it has to reach for a heap slice, so a line with a few attributes often needs no slice allocation at all. Put together, a `LogAttrs` line with the built-in JSON handler commonly benchmarks at zero or one allocation per call, against a few for the variadic form. "Commonly" is doing real work in that sentence: the exact numbers depend on the handler, the number of attributes and the compiler version, which is why the honest answer ends with a benchmark rather than a figure. ## slog.Any is the exception `slog.Any(key, value)` takes an `any`, so it puts the boxing straight back: the value is converted to an interface exactly as it would be in the variadic form, and the handler then has to fall back on reflection or `encoding/json` to render something it has no typed path for. `slog.Any` is the right tool when the type genuinely is not known, and the wrong reflex when a typed constructor exists. ## Measuring it The diagnostic that settles this is a benchmark with allocation reporting: ``` go test -bench=. -benchmem ``` `-benchmem` adds two columns to the usual `ns/op`: `B/op`, bytes allocated per iteration, and `allocs/op`, the number of distinct allocations. Point both benchmarks at the same handler writing to `io.Discard` so you are comparing call forms and not I/O, and call `b.ReportAllocs()` if you would rather not depend on the flag. ## When it is worth it The variadic form is shorter and reads better, and outside a hot path the difference is invisible next to the cost of formatting and writing the line. Reach for `LogAttrs` where the call runs per record in a stream, per row in a batch job, or inside a library that other people will drop into their own hot loops. Rewriting every log line in a service to `LogAttrs` buys nothing measurable and costs readability on every cold path you touch.

  • Where exactly do the extra allocations in the variadic form come from?
    Two places. The call site builds a `[]any` and converts each argument to an interface value, which heap-allocates every non-pointer value it cannot keep in the frame. Then slog decodes the slice pairwise at run time, type-asserting each key to a string and constructing an `Attr` from each pair — work `LogAttrs` never does because the attributes arrive already typed.
  • If LogAttrs is cheaper, why do the variadic methods exist at all?
    Ergonomics. `logger.Info("done", "shard", 7)` is far shorter than the `LogAttrs` equivalent, needs no context argument and no level constant, and on a path that runs occasionally the difference disappears next to formatting and writing the line. Spend the verbosity where the call is hot; keep the short form everywhere else.
  • Does slog.Any bring the boxing back?
    Yes. `slog.Any(key, v)` stores `v` in the attribute's `Value` as an interface, so a non-pointer value is boxed exactly as it would be in the variadic form, and the handler then needs reflection or `encoding/json` to render it. Use `slog.String`, `slog.Int`, `slog.Duration` and the other typed constructors whenever the type is known.

saying these in an interview costs you the question

  • Says LogAttrs is faster because it skips the level check
  • Believes converting a value to any never allocates
  • Assumes slog.Any and slog.String cost the same
  • Claims the difference is in formatting, not at the call site
  • Rewrites every call as LogAttrs, including cold paths