When does a call to a Go `...any` variadic helper allocate, and how would you keep that off a hot path?
answer
- separate building it from passing it on
- the call site, not the layer count
- escape analysis decides the heap part
- forwarding with args... adds nothing
- a fixed-arity variant needs no slice
basics
~20 sEach call site listing loose arguments builds a fresh slice for the variadic parameter, which lands on the heap only if it escapes. Forwarding with args... builds nothing extra; a fixed-arity variant avoids the slice altogether.
solid answer
~50 sSeparate two things. **Building** the variadic slice happens once per call site that lists loose arguments — `render(tmpl, name, locale)` materialises a `[]any` — and whether that costs a heap allocation is an escape-analysis question: if nothing the callee does lets the slice outlive the call, it can stay on the stack. **Forwarding** is nearly free by comparison: a wrapper calling `render(tmpl, args...)` passes the slice it already holds, so a chain of wrappers does not multiply the work. So when a `-benchmem` run on the mail-merge composer shows allocations tracking recipient count, look at the outermost call site and at what the callee retains, not at how many layers the call passes through. The behaviour-preserving fixes are a fixed-arity variant for the common shape, hoisting the formatting out of the per-recipient loop, or writing into a caller-supplied destination instead of returning fresh values.
code
go · 13 linesfunc render(tmpl string, args ...any) string {
return fmt.Sprintf(tmpl, args...)
}
// this call site materializes a []any on every iteration
for i, r := range recipients {
out[i] = render(greeting, r.Name, r.Locale)
}
// the wrapper forwards the slice it already has; nothing new is built
func renderFor(tmpl string, args ...any) string {
return render(tmpl, args...)
}go deeper
Know that calling a variadic function with loose arguments quietly builds a slice for you. It is usually cheap, but it happens once per call and it is not literally free.
Explain the split between the call site that materialises the slice and the forwarding call that reuses one, and say what escape analysis must prove for that slice to stay on the stack.
Lead with the measurement: a -benchmem run tying allocations to a specific call site, then one behaviour-preserving change — a fixed-arity variant, or hoisting invariant work out of the per-item loop — and a re-measurement.
Own the API trade. A second fixed-arity entry point buys allocations back at the price of a wider surface every caller must choose from. Decide when that is worth it and when the team should leave the variadic alone.
## Two costs that get confused with each other When someone says "variadic calls allocate", they are usually running together two separate mechanisms, and telling them apart is the whole senior answer. **Materialising the slice.** A call site that lists loose arguments — `render(tmpl, r.Name, r.Locale)` — needs a backing array to put those arguments in. The compiler emits code to build it. That happens once per call, at the call site, and it happens whether the parameter is `...int` or `...any`. **Forwarding an existing slice.** A wrapper whose own parameter is `args ...any` and which calls `inner(tmpl, args...)` builds nothing. The spread form passes the header it already has. Add five wrapper layers and you have added five function calls, not five slices. This surprises people who assume each layer "re-packs" the arguments. ```go func render(tmpl string, args ...any) string { return fmt.Sprintf(tmpl, args...) } // this call site materializes a []any on every iteration for i, r := range recipients { out[i] = render(greeting, r.Name, r.Locale) } // the wrapper forwards the slice it already has; nothing new is built func renderFor(tmpl string, args ...any) string { return render(tmpl, args...) } ``` ## Stack or heap is an escape-analysis question Materialising the slice does not automatically mean a heap allocation. Escape analysis is a compile-time decision: the compiler heap-allocates a value only when it cannot prove the value's lifetime ends with the frame. If the variadic slice is used and dropped inside the callee, it can live on the stack and cost nothing an allocation counter would see. If the callee stores it somewhere, returns something derived from it that keeps it alive, or hands it to code the compiler cannot see through, the slice escapes and you pay. That is why the honest answer to "does this allocate" is *measure it*, not *yes* or *no*. The measurement is a benchmark run with `-benchmem`, which reports bytes and allocations per operation alongside the timing: ``` go test -bench=Compose -benchmem ./composer ``` Run it, change one thing, run it again. A composer whose allocations per operation scale with the recipient count is telling you the cost is in the per-recipient loop, and the two candidates inside that loop are the slice the call site builds and whatever the callee does with it. ## Fixes that do not change behaviour **A fixed-arity variant.** Offer `render1(tmpl string, a any)` beside `render(tmpl string, args ...any)`, and let the hot one-argument path use it. No slice is built at all. The cost is a wider API surface: two spellings of the same operation that every reader must now choose between. Justify it with a benchmark or do not do it. **Hoist the work out of the loop.** Often the variadic call is not the real cost — the formatting behind it is, and it is being redone per recipient for a message whose shape never changes. Precomputing the parts that do not vary per recipient removes far more than the slice ever did. **Write into a caller-supplied destination.** Handing the callee somewhere to write, rather than having it return freshly built values, moves the allocation decision to the caller, who can reuse across iterations. ## The reuse trap The tempting fourth fix is to build one `[]any` before the loop and spread it every iteration, overwriting the elements each time. Sometimes that is correct and sometimes it is a bug you will spend an afternoon on. Because the spread hands the callee **your** slice, a callee that retains it — or retains a value derived from it — keeps looking at storage you are about to overwrite on the next iteration. Reuse is safe only for a callee you own, that you know is synchronous, and that you know does not keep a reference. Against a callee you do not control, the saved allocation is not worth the aliasing risk. ## The order to work in Measure with `-benchmem`; identify whether allocations scale with call count or with input size; check whether the cost is the slice the call site builds or what the callee retains; change one thing; measure again. Reaching for a fixed-arity API before that sequence is how a codebase acquires two entry points for every helper and no measurable improvement.
- How would you confirm which call site is responsible?Benchmark with `-benchmem` so allocations per operation are reported, then vary one thing at a time: run the wrapper chain with a pre-built slice forwarded through it, and separately run the leaf call with loose arguments. If only the second moves, the cost is the call site materialising the slice rather than the depth of forwarding.
- Is it safe to build one `[]any` before the loop and reuse it for every item?Only if nothing retains it. The spread hands the callee your slice, so a callee that stores it — or something derived from it — keeps observing storage the next iteration overwrites. Reuse is fine for a synchronous, non-retaining callee you own; against anything else the saved allocation is not worth the aliasing risk.
- What does a fixed-arity variant look like, and when is it worth the API noise?A sibling function taking the arguments explicitly — `render1(tmpl string, a any)` alongside `render(tmpl string, args ...any)` — so the common one-argument call builds no slice. It is worth it only once a benchmark puts that call in the hot path; two spellings of one operation is a real cost to every future reader.
- Does the number of wrapper layers between the call site and the leaf matter?Barely. Each layer that forwards with `args...` passes the same slice header on, so the layers cost function calls, not slices. If a profile-driven refactor is flattening wrapper layers to save allocations, it is optimising the wrong thing — the slice was built once, at the outermost call site that listed loose arguments.
saying these in an interview costs you the question
- Assumes every wrapper layer builds a new slice
- Says a variadic call always allocates on the heap
- Optimises before running a benchmark with -benchmem
- Reuses one shared slice across calls that retain it
- Confuses building the argument slice with formatting the output
- Adds a second entry point without a measurement to justify it