skip to content

In Go, when you forward a slice with `f(s...)`, can the callee's writes to that parameter change the caller's slice?

level: middleimportance: should knowfreq 52%

answer

  1. one call form is different from the others
  2. no new slice is built there
  3. the callee gets the same header
  4. same pointer, same backing array
  5. literal-argument tests never catch it

basics

~20 s

Yes. The three-dot spread passes the caller's slice itself rather than a copy, so both sides index one backing array. Any element the callee writes through the variadic parameter is visible to the caller afterwards.

solid answer

~40 s

The spread is the one call form that builds nothing: the variadic parameter receives the caller's slice header — same pointer, same length, same capacity — so both sides address the same backing array. In a notification composer, a `func normalize(addrs ...string)` that lowercases `addrs[i]` in place silently rewrites the recipient list a caller handed over with `normalize(list...)`. Called as `normalize("Ann@x", "Bo@x")` the same function is harmless, because that call site materialises a private slice nobody else holds — which is precisely why unit tests written with literal arguments never catch the bug. If a variadic function must mutate its arguments, either say so in the doc comment, or copy first with `local := make([]string, len(addrs)); copy(local, addrs)`, or take an explicit `[]string` parameter so the sharing is visible in the signature.

code

go · 9 lines
go
func normalize(addrs ...string) {
	for i := range addrs {
		addrs[i] = strings.ToLower(addrs[i])
	}
}

recipients := []string{"[email protected]", "[email protected]"}
normalize(recipients...) // recipients itself comes back lowercased
normalize("[email protected]") // a private slice; nothing is shared

go deeper

for a junior

Remember that the three-dot spread hands over the slice you already hold, so a function that writes to its variadic parameter is writing to your data.

for a middle

Explain the mechanics: the parameter receives the same header — pointer, length, capacity — so element writes land in the caller's backing array, while a loose-argument call site builds a private one.

for a senior

Diagnose it out loud: the same function is safe at one call site and destructive at another, and tests written with literal arguments never fire. Say how you would copy defensively or make the sharing explicit in the signature.

for a principal

Own the contract. Decide whether your package's variadic functions may mutate arguments at all, write that rule into the doc comments and hold reviews to it, because callers cannot see the difference from the signature.

## Two call forms, two very different memory stories A variadic parameter is a slice, and a Go slice value is a three-word header: a pointer to a backing array, a length and a capacity. Passing it copies the header, never the array. That single fact drives the whole answer. When you call with loose arguments — `normalize("Ann@x", "Bo@x")` — the compiler builds a backing array **for that call**, forms a slice over it and passes the header. The callee is the only holder of that array, so it may write into it freely and nothing outside notices. When you call with the spread — `normalize(list...)` — there is no new array. The parameter gets the header of `list`, pointing at the array `list` already points at. Now the callee and the caller are two names for the same storage. ```go func normalize(addrs ...string) { for i := range addrs { addrs[i] = strings.ToLower(addrs[i]) } } ``` Call it as `normalize(recipients...)` and `recipients` comes back lowercased. Nothing in the signature warned you. `...string` looks exactly as harmless as it did at the literal-argument call site. ## Why this bites in real code and not in tests This is the shape of the failure that makes it worth an interview question. A mail-merge composer has one call site that spreads a live recipient list, and a test suite whose calls all look like `normalize("a@x", "b@x")`. The test call sites build their own arrays, so the mutation is invisible there and every test is green. The production call site shares the caller's list, and downstream code that expected the original casing — a dedupe against a stored set, an audit log, a retry that re-reads the list — quietly works on rewritten data. Nothing in the toolchain flags it. `go vet` has no check for a callee writing through a shared backing array, and the race detector is silent because there is no race: one goroutine, ordinary ordered writes. The only mechanical way to surface it is a test that spreads a slice and then asserts on **that slice** after the call returns. ## What to do about it **Copy at the top, if the function must reorder or rewrite.** `local := make([]string, len(addrs)); copy(local, addrs)` gives you a private array; `copy` moves `min(len(dst), len(src))` elements and returns that count. This costs one allocation per call, which is usually irrelevant and occasionally is not — measure before you decide it matters. **Or document the mutation.** "Reorders its arguments" is a perfectly respectable contract; the standard library has several functions that sort in place. It stops being respectable when it is undocumented, because a caller reading `normalize(addrs ...string)` has no way to infer it. **Or change the signature.** `func normalize(addrs []string)` says out loud that you are handing over a slice, and readers of the call site are primed to think about aliasing in a way that `normalize(list...)` does not prime them. When a function only ever gets called with a slice anyway, the variadic sugar buys three characters and costs clarity. **Or return new values instead of mutating.** For a composer that produces normalised addresses, returning a fresh `[]string` sidesteps the whole question, at the price of an allocation the caller can see. ## The mirror-image mistake The other way to get this wrong is to over-correct: to believe that because slices share storage, a callee can also change the caller's **length**. It cannot. The header is copied, so `addrs = addrs[:1]` inside the callee reslices only the callee's copy of the header, and the caller's length is untouched. What crosses the boundary is writes to elements the caller can still see through its own header — nothing about the header itself. So the rule to carry: **the spread form shares elements, not the slice variable.** Mutating `addrs[i]` reaches the caller; rebinding `addrs` does not.

  • Does calling the same function with loose arguments share anything with the caller?
    No. `normalize("a@x", "b@x")` makes the compiler build a backing array for that call alone, and nobody else holds a reference to it, so the callee may write into it freely. The sharing exists only for the spread form, which is why one function can be safe at one call site and destructive at another in the same package.
  • How would you write the function so the caller's data cannot be touched?
    Copy at the top: `local := make([]string, len(addrs)); copy(local, addrs)`, then work on `local`. `copy` moves `min(len(dst), len(src))` elements and returns that count. If copying on every call is genuinely too expensive, keep the mutation but state it in the doc comment — an undocumented in-place rewrite is the actual defect, not the rewrite itself.
  • Would `go vet` or the race detector catch this?
    Neither. It is not a data race — one goroutine, ordered writes — so a `-race` build stays silent, and `go vet` has no check for a callee writing through a shared backing array. The only reliable detector is a test that spreads a slice into the call and then asserts on that same slice afterwards.
  • Can the callee change the caller's slice length by reslicing the parameter?
    No. The header is copied on the way in, so `addrs = addrs[:1]` inside the callee rebinds only the callee's own header and the caller's length is unaffected. What crosses the boundary is writes to elements the caller can still reach through its own header — sharing is about the array, not about the variable.

saying these in an interview costs you the question

  • Assumes every call copies the arguments
  • Says slices are passed by value so mutation cannot escape
  • Tests only with literal arguments and declares it safe
  • Blames a data race and reaches for the race detector
  • Claims the callee receives a defensive copy
  • Thinks reslicing the parameter shortens the caller's slice