skip to content

If Go copies every argument, why can a function mutate the caller's map and slice elements?

level: middleimportance: should knowfreq 58%

answer

  1. Copying the value, not what it refers to
  2. Ask what is actually inside the value
  3. Three words: pointer, length, capacity
  4. A map value is one handle
  5. Element writes travel; length does not

basics

~20 s

Because what gets copied contains a pointer. A slice value is a three-word header (array pointer, length, capacity) and a map value is a handle to one hash table, so the copy still refers to the same underlying data. Writes through it are shared; changes to the header itself are not.

solid answer

~50 s

Go really does copy the argument — but for a slice the value being copied is the three-word **header** {pointer to backing array, len, cap}, and for a map it is a handle pointing at one runtime hash table. Copying a handle duplicates the handle, not the data behind it, so `s[0] = 99` and `m["k"] = 1` inside the callee are visible to the caller. What is *not* shared is the header itself: the callee's `len` and `cap` are its own copy, which is why a function that appends must return the new slice for the caller to see the longer view. The same shape explains interface values (a copied two-word type/value pair, sharing whatever the value points at), channels and function values. The distinction to state clearly is: **copying the value versus copying what the value refers to** — Go always does the first and never does the second.

code

go · 13 lines
go
func mutate(s []int, m map[string]int) {
	// shared: the copied header points at the caller's array
	s[0] = 99

	// not shared: len and cap live in this function's copy
	s = append(s, 1)

	// shared: a map value is a handle to one hash table
	m["hits"] = 1

	// not shared: this rebinds the local handle only
	m = make(map[string]int)
}

go deeper

for a junior

Know the practical outcomes: a callee can change slice elements and map entries you passed in, but you must assign the result of append yourself. Do not call these reference types.

for a middle

Explain the mechanism — the slice header's three words and the map handle — and use it to predict which writes cross the call boundary and which stay in the copy.

for a senior

Show it in API design: whether a function that receives a slice may retain or mutate it, why a slice-returning signature is clearer than a *[]T out-parameter, and how struct fields quietly alias after a copy.

for a principal

Own the boundary rule: what your package promises about slices and maps it accepts and hands out — copy on entry, copy on exit, or share — is a contract that callers will rely on and that you cannot quietly tighten later.

## Two things people conflate "Go passes everything by value" and "a function can change my map" are both true, and they are only in tension if you assume a value contains all its data. Several Go types are small **handles** to larger structures, and copying a handle duplicates the handle while leaving the structure shared. ## Slices: a three-word header A slice value is a struct of three words: - a **pointer** to the first element of a backing array, - a **length** — how many elements this view exposes, - a **capacity** — how many the backing array holds from that pointer onward. Passing a `[]int` copies those three words. The pointer in the copy is the same address, so both headers view the same array: ```go func zeroFirst(s []int) { s[0] = 0 // caller sees this } ``` The write goes through the shared pointer into the shared array. But this does not: ```go func grow(s []int) { s = append(s, 1) // caller's len is unchanged either way } ``` `append` returns a new header — possibly with a new pointer, certainly with a larger `len` — and assigning it updates only the callee's copy. That is precisely why `append`'s result must be assigned back, and why functions that lengthen a slice return it (`func addItem(s []T, x T) []T`) rather than mutating the parameter. If you truly need the caller's *variable* re-pointed, take a `*[]int`; that is rare and usually a sign the return value is the better API. ## Maps and channels: handles A map value is a pointer-sized handle to the runtime's hash table. Copying it copies the handle, so: ```go func record(m map[string]int) { m["hits"]++ // caller sees this } ``` There is no separate "map object" in the caller to keep in sync — there is one table and now two handles to it. The same is true of channels: a copied channel value refers to the same channel, which is what makes `func worker(jobs <-chan Job)` work at all. Function values behave similarly — copying the value copies a pointer to code plus any captured environment. A consequence worth stating: because a map is a handle, you cannot "reset the caller's map" by assigning `m = nil` or `m = make(map[string]int)` inside the callee. That rebinds the local handle. Use `clear(m)` if you mean to empty the shared table, or return a new map. ## Interface values: a copied pair An interface value holds two words: the dynamic **type** and a pointer to (or, for pointer-shaped values, directly) the **dynamic value**. Passing an interface copies the pair. If the concrete value stored in it is a `*Player`, both copies point at the same `Player` and mutations through either are shared. If the concrete value is a `Player` struct stored by value, the interface owns its own copy and the caller's original is untouched. The interface does not change the rule — it just adds one more layer where a copy might or might not carry a pointer. ## Structs: exactly what they contain A struct copy is field by field. Fields that are `int`, `string` headers, arrays or nested structs are duplicated; fields that are pointers, slices, maps, channels or functions are duplicated *as handles*, leaving the pointed-to data shared. So: ```go type Player struct { Name string Inventory []Item // copied header, shared items Effects map[string]int // copied handle, shared table Target *Player // copied pointer, shared target } ``` Copying a `Player` gives you an independent `HP` and `Name` but an `Inventory` that aliases the original's items. That is neither a bug nor a guarantee — it is the direct consequence of what the fields are — but it is the reason "I copied the struct so it's mine now" is a dangerous sentence. ## Arrays are the counterexample `[8]int` is not a handle. It *is* the eight ints, so copying it copies all of them, and passing a large array to a function copies the whole thing on every call. That contrast — array copies wholesale, slice copies a three-word view — is the cleanest way to demonstrate that Go has one rule, not several. ## How to answer this in an interview Say the rule, then say what is inside the value. "Go copies the argument. For a slice the argument is a header containing a pointer, so the copy views the same array — element writes are shared, but the length lives in the copy." That sentence covers slices, maps, channels, interfaces and struct fields in one breath, and it is the answer that shows you understand the mechanism rather than a list of memorised exceptions.

  • Why must the result of append be assigned back to a variable?
    Because append returns a header, and the header you passed in was a copy. Even when the write lands in the same backing array, the new length exists only in the returned value, so discarding it loses the element. Returning the slice is also how Go keeps the reallocation case honest: when the array had to grow, the returned header points somewhere new.
  • How would you write a function that replaces the caller's whole slice variable?
    Return the new slice and let the caller assign it — that is the idiomatic shape and the one every stdlib function uses. A `*[]T` parameter does work, since writing `*sp = newSlice` reaches the caller's variable, but it reads as an out-parameter and is reserved for cases where a return value genuinely does not fit, such as a method that must keep a signature.
  • If a function takes an interface parameter, can it mutate the caller's concrete value?
    Only if the concrete value stored in the interface is a pointer. The interface value — the type/value pair — is copied; when the value word is a *Player, both copies aim at the same Player and mutation is shared. When a Player struct was stored by value, the interface holds its own copy and the caller's variable is untouched.
  • Does passing a large array behave the same way as passing a slice?
    No, and it is the sharpest contrast in the language. An array value contains its elements, so passing a [1024]int copies all 1024 words and the callee's writes are invisible to the caller. A slice passes three words regardless of how many elements it views. Passing arrays by value is almost always a mistake; pass a slice of it instead.

A slice value is a library card, not the book. Photocopying the card gives two people access to the same book; scribbling a new due date on your copy of the card changes nothing for anyone else.

saying these in an interview costs you the question

  • Says slices and maps are passed by reference
  • Thinks Go has reference types with different call semantics
  • Expects the caller to see a length change from append
  • Believes assigning m = nil inside a callee clears the caller's map
  • Claims copying a struct deep-copies its slice field
  • Cannot name what the three words of a slice header are