Why can a Go function mutate a slice's elements but not change the caller's length?
answer
- Go has no pass-by-reference at all
- the callee gets its own three words
- one word is shared, two are private
- writing through s versus writing to s
basics
~20 sA slice is passed by value, so the callee gets its own copy of the three-word header. Writes through the shared pointer reach the caller's elements; assigning a new length or capacity only changes the callee's copy.
solid answer
~40 sGo has no pass-by-reference: a `[]float64` parameter is a copy of the caller's three-word header - pointer, length, capacity. The pointer word in that copy names the same backing array, so `s[0] = 1` writes into the caller's elements and is immediately visible. But `s = s[:0]`, `s = nil` or `s = make([]float64, 10)` only overwrite the callee's own header, which is discarded when the function returns; the caller's length and capacity are untouched. That asymmetry is why Go's convention is that any function which may change a slice's length **returns** the new slice for the caller to assign, and why taking `*[]float64` is the only way to write a new header back through a parameter.
code
go · 11 linesfunc Fill(s []float64) {
for i := range s {
s[i] = 1
}
s = s[:0] // only this function's copy of the header
}
readings := make([]float64, 4)
Fill(readings)
// 4 1 - the re-slice was invisible, the writes were not
fmt.Println(len(readings), readings[0])go deeper
Remember that Go copies every argument. A function receives a copy of the slice value, so writing s[0] reaches the caller while writing s = something does not.
Explain the mechanics precisely: the copied header shares one backing-array pointer, so element writes travel, while the callee's own length and capacity words live and die in its frame.
Show the API habit that follows - a function that may change a slice's length returns the new slice, and *[]T appears only when the header genuinely has to be written back through the parameter.
Set and enforce the convention. Whether library functions may mutate a caller's elements in place is a contract other teams depend on, so it belongs in the signature and the doc comment rather than being discovered from a bug report.
## Everything in Go is passed by value There is no pass-by-reference in Go. Every argument, every assignment and every value receiver copies the value. For a slice, the value being copied is the three-word header: a pointer to the first element in a backing array, an `int` length, and an `int` capacity. So when a library exposes ``` func Normalise(s []float64) ``` the function's `s` is a *separate* header from the caller's. Two headers, one backing array. ## The half that travels, and the half that does not The pointer word is shared, so anything reached **through** it is shared: - `s[0] = 1` writes into the caller's backing array. Visible. - `for i := range s { s[i] = 0 }` clears the caller's elements. Visible. - `clear(s)` zeroes the caller's elements. Visible. The length and capacity words are private copies, so anything that **assigns to `s` itself** is not: - `s = s[:0]` - the callee's length becomes 0; the caller's is unchanged. - `s = s[2:]` - the callee's pointer moves; the caller's does not. - `s = nil` - the callee's header is zeroed; the caller still has its slice. - `s = make([]float64, 10)` - the callee now points at a brand-new array; the caller points at the old one, unmodified. The rule that covers all of these in one line: *writing through `s` is visible, writing to `s` is not.* ## Why "slices are passed by reference" is the wrong mental model It is a tempting shorthand because element writes behave the way a reference would, and it produces the right answer half the time. It then produces exactly the wrong answer for the other half - the half that shows up in a bug report. A candidate who says "slices are references" cannot explain why `s = s[:0]` inside the function did nothing, and cannot predict which of two very similar-looking lines will be seen by the caller. The accurate statement is: a slice is a value, and one of its three fields happens to be a pointer. ## The two supported ways to change the caller's header **Return it.** This is the idiomatic form and the one the standard library uses everywhere: ``` func Trim(s []float64, n int) []float64 { return s[:n] } readings = Trim(readings, 4) ``` The caller performs the assignment, so the caller's header changes. **Take a pointer to it.** `*[]float64` is a pointer to the header itself, so `*s = (*s)[:2]` overwrites the caller's three words directly. It is correct but rarer, because it makes the signature harder to read and gives up the composability of returning a value. Reach for it when the function must update a slice field in place, or when the slice is one of several things being written back. ## The mirror-image trap The asymmetry cuts both ways. Because the array is shared, a parameter that *looks* read-only is not. A library function that quietly sorts or zeroes the `[]float64` it was handed is mutating a caller's data, and the caller has no syntactic warning: `[]float64` and "a read-only view of some numbers" are the same type. If a function must not touch the caller's elements, it has to copy them first, and if it *does* mutate them that belongs in the doc comment, because it is part of the contract. ## The same reasoning elsewhere The rule generalises to any value with a pointer inside it. A method with a value receiver whose struct has a slice field can write to that field's elements and the caller sees it, but assigning a whole new slice to the field only touches the receiver copy. Maps and channels behave slightly differently: their values are pointers to a runtime structure with no user-visible length word to reassign, so inserting into a map inside a function *is* visible to the caller. Arrays behave in the opposite way from slices - `[16]float64` copies all sixteen elements, so nothing a function does to it is visible outside. ## Seeing it A debugger settles this in seconds. Stop on the call, expand both the caller's slice and the parameter, and you will see two headers with equal data pointers. Step past the callee's `s = s[:0]` and watch only the callee's length word change. That picture is worth more than any amount of prose about pass-by-value.
- If the callee cannot change the caller's length, why do element writes get through?Because only the header is copied, not the elements. The copied header's pointer word names the same backing array as the caller's, so `s[0] = 1` stores into memory both headers describe. The length and capacity words, by contrast, exist only in the callee's frame, so overwriting them changes nothing the caller can observe.
- How would you write a library function that must shorten the caller's slice?Return the shortened slice and let the caller assign it - `readings = Trim(readings, 4)` - which is the idiomatic Go form and keeps the signature composable. Take `*[]float64` only when the function genuinely has to write the header back in place, for example when it is updating a struct field alongside other outputs.
- Does the same asymmetry apply to a map parameter?Not in the same way. A map value is a pointer to a runtime structure, with no user-visible length word in your copy, so inserting or deleting inside the function is visible to the caller. Assigning a whole new map to the parameter is still local. Arrays are the opposite extreme: `[16]float64` copies every element, so nothing done to it escapes the call.
Handing a colleague a photocopy of your index card. They can walk to the shelf and rewrite a book, and you see the change. If they cross out the count written on their photocopy, your card still says what it always said.
saying these in an interview costs you the question
- Says slices are passed by reference in Go
- Expects the callee's s = s[:0] to shorten the caller's slice
- Treats element writes and length changes as behaving alike
- Claims every mutation requires a *[]T parameter
- Assumes a []T parameter is read-only unless documented otherwise