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.

A slice header is three parts — pointer, length, capacity — pointing at a backing array on the heap

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:

live trace · appendInside(s)
CALLER: s PTR 0xABC LEN 3 CAP 5 FUNCTION: x (copy of header) PTR 0xABC LEN 3 CAP 5 SHARED BACKING ARRAY 1 2 3 · · caller's window — len 3 function's window — len 3
step 1 of 6 · setup

The same trace, in words:

  1. Caller’s header is {ptr=0xABC, len=3, cap=5}. Backing array: [1, 2, 3, _, _].
  2. appendInside(s) is called. Go copies the 3-part header into the function’s parameter x. Two headers, one shared array.
  3. x = append(x, 99). Since len < cap, no reallocation — 99 is written in place at index 3. The function’s local header advances to len = 4. The caller’s header is untouched.
  4. 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.
  5. The caller does s = append(s, 50). append reads 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.

Three different windows (s, t, u) onto the same 12-element array. s and t overlap on cell 4.

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.

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:

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.

Top panel: today's Go — a 24-byte slice header lives inline on the stack frame, pointing at a single heap-allocated backing array (1 GC entry). Bottom panel: reference semantics — only an 8-byte pointer on the stack, with both the header and the array as heap objects (2 GC entries).

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 numpyarr[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.