Anatomy of a Copy
A function appends an element to a slice. The caller of the function then appends to the same slice. One of the appends silently disappears. No concurrency, no shared state across goroutines — just two append calls, back to back. This is a famous Go bug that confuses everyone the first time.
It all dissolves once you internalise a single rule: Go is pass-by-value, always. When you pass a variable to a function (or a goroutine), the runtime copies its bytes into the function’s parameter. Nothing else happens. The mystery is only that what those bytes mean varies by type.
What gets copied
The bytes copied depend on what kind of variable you’re holding. Here’s how the main Go types break down:
| Type | What’s in the variable | What’s copied | What’s shared with the original |
|---|---|---|---|
int, bool, float64 |
the value itself | the value | nothing |
string |
header (ptr + length) | the header | bytes (but immutable, so safe) |
struct{...} |
all fields inline | all fields | nothing (unless a field is a ptr/slice/map) |
[N]int (array) |
all N elements inline | all N elements | nothing |
[]int (slice) |
header (ptr + len + cap) | the header | the backing array |
map[K]V |
pointer to internal hash table | the pointer | the hash table |
chan T |
pointer to channel runtime object | the pointer | the channel |
*T (pointer) |
a memory address | the address | the pointed-to value |
func(...) |
code ptr + captured environment | both | the captured variables |
The bold entries in the last column are where concurrency bugs live. Pass scalars and strings freely; they’re effectively immutable once handed off. The moment you pass a slice, map, channel, or pointer, you’ve started sharing memory.
Anatomy of a slice
A slice variable is three parts: a pointer to the first element, the length, and the capacity. On a 64-bit machine, that’s 24 bytes. The elements themselves live on the heap, separate from the header.
Copying a slice copies those three parts. The new copy still points at the same backing array. That’s the source of every “wait, mutations propagate?” moment in Go.
Subslicing works the same way. s[1:3] doesn’t copy any elements — it constructs a new header (different ptr, different len, different cap) that’s a different window onto the same backing array.
The bug
Here’s the canonical example:
func appendInside(x []int) {
x = append(x, 99)
}
s := make([]int, 3, 5)
s[0], s[1], s[2] = 1, 2, 3 // s = [1 2 3], len 3, cap 5
appendInside(s) // function appends 99
s = append(s, 50) // caller appends 50
fmt.Println(s) // [1 2 3 50] — the 99 is gone
Walk through what happens — hit play, or step through manually:
The same trace, in words:
- Caller’s header is
{ptr=0xABC, len=3, cap=5}. Backing array:[1, 2, 3, _, _]. appendInside(s)is called. Go copies the 3-part header into the function’s parameterx. Two headers, one shared array.x = append(x, 99). Sincelen < cap, no reallocation — 99 is written in place at index 3. The function’s local header advances tolen = 4. The caller’s header is untouched.- The function returns. Its header vanishes with the stack frame. The 99 sits in the array, but the caller’s window still ends at index 2.
- The caller does
s = append(s, 50).appendreads the caller’s header:len=3, cap=5— room to spare. It writes 50 at index 3, silently overwriting the 99.
No panic, no warning, no go vet complaint. The 99 is simply gone.
A slice is a window into an array
The clean mental model: a slice isn’t a list, it’s a window into an array.
Each window has a position, a width (len), and a maximum width (cap). Passing a window to a function passes a copy of the window — same array beneath, but the function’s window can widen or shift without affecting the caller’s.
- Append within
cap— the new byte lands in the shared array, but only the appender’s window widens to include it; the other side’slenis still its old copy, so the element sits outside its window. - Append past
cap— Go allocates a brand-new array; the appender’s window now points at a different array entirely; the other window still sees the old one.
The bug above fits this model exactly. The function’s window widened to 4 and saw the 99 it wrote. The caller’s stayed at 3. The 99 sat in the array, behind the right edge of the caller’s window, until the next append through that narrower window overwrote it.
Why Go chose this
This isn’t an oversight. The Go authors had two reasonable paths — value semantics (what they chose) or reference semantics like Python’s list (what they didn’t). Four reasons they went the more explicit way:
- Consistency. Everything in Go is pass-by-value. No exceptions, no “well, slices are special”. A single mental model covers every type.
- Visible allocation. Writing
s = append(s, x)tells the reader this might reallocate (when len == cap). - Stack-friendly. A slice header lives inline on the stack frame that holds the variable — 24 bytes sitting flat in memory like any small struct. Passing it to a function copies those 24 bytes onto the next stack frame. Zero heap allocation, zero GC tracking. Only the backing array lives on the heap.
- Opt-in sharing. When you do want a function to mutate your variable, pass
*[]int. The pointer makes the intent clear.
The third one is interesting, and it’s worth pausing on. Today, a slice header lives inline in whichever stack frame holds the variable — 24 bytes (pointer, length, capacity). Passing it to a function copies those 24 bytes onto the next stack frame. Zero heap allocation, zero GC tracking. Only the backing array lives on the heap, and only one allocation is paid for, no matter how many functions hold copies of the header.
If slices were reference-semantic — like Python’s list — every header would need its own heap home. Why? Because multiple functions would all need to point at the same header to share its mutable length. A stack frame can’t contain shared mutable state; it gets reused the moment its function returns.
The same slice. In Go, the header costs nothing — it’s just bytes on the stack that pop away when the function returns. In the alternate timeline, that same header becomes a tracked heap object the garbage collector has to inspect and eventually sweep.
// every slice header lives on the stack of whoever holds it.
// only the backing array is on the heap. one GC entry per slice — total.
func process() {
s := []int{1, 2, 3} // header: 24 B on stack · array: 1 heap alloc
f(s) // copies 24 B onto f's stack · 0 new allocs
g(s) // copies 24 B again · 0 new allocs
} // stack frames pop · array becomes garbage when unreachable
// every slice header is heap-allocated so multiple functions can share
// and mutate it. now there are TWO heap objects per slice — header + array.
func process() {
s := []int{1, 2, 3} // header: 1 heap alloc · array: 1 heap alloc
f(s) // copies the pointer · 0 new allocs (but header is GC-tracked)
g(s) // copies the pointer · 0 new allocs
} // GC must scan and sweep both
A typical Go program creates millions of slices — every strings.Split, every JSON unmarshal, every iteration that builds an intermediate result. Today, almost all of those headers live and die on the stack: untracked, near-free. Under reference semantics, every one of them would have been a heap object the garbage collector had to keep track of.
So to rephrase, Go’s slice design isn’t a quirky aesthetic choice — it’s the cheapest way to get a flexible, shareable view over an array without paying GC for the privilege.
The Python contrast
One reasonable reaction: “if Python is reference-semantic, shouldn’t slicing share storage the way Go does?” Now, that’s a different design choice.
# slicing makes a fresh list
l = [0, 1, 2, 3]
x = l[0:2]
x[0] = 10
print(l) # [0, 1, 2, 3]
print(x) # [10, 1]
// slicing shares the array
l := []int{0, 1, 2, 3}
x := l[0:2]
x[0] = 10
fmt.Println(l) // [10 1 2 3]
fmt.Println(x) // [10 1]
Python is reference-semantic at the variable level. y = l shares the same list; l[0:2] allocates a new list (Not sure why, should dig deeper). Go does the inverse: variables are value-semantic, but l[0:2] constructs a new header pointing at the same memory.
| Operation | Python list | Go slice |
|---|---|---|
y = l |
shares — same object | shares — header copy, same array |
y[0] = 10 |
l sees it | l sees it |
y.append(99) / append(y, 99) |
l sees it (same list) | l doesn’t (header copy) |
x = l[0:2] |
copies to a new list | shares the backing array |
x[0] = 10 after slicing |
l unaffected | l affected |
When Python does want shared views, the convention is to use numpy — arr[0:2] on a numpy array is a view, not a copy. The Python community decided “default slicing copies, opt into views with numpy”. The Go community decided “default slicing is a view, opt into copies with copy()”.
Two independent choices: variable semantics (reference vs value) and slice-operator semantics (copy vs view). Python is reference-then-copy; Go is value-then-view. Both are internally consistent yet surprising.