Tricky parts of Golang. The Slice Header and the Shared Backing Array

Slices are the bread and butter of Go programming. Most developers use them daily without thinking twice — and that comfort is exactly where subtle, load-dependent bugs come from. This article pulls back the curtain on how slices are represented in memory and explains the class of bug that emerges when two slices quietly share the same underlying array.

The Surprise

original := []int{1, 2, 3, 4, 5}
sub := original[1:3] // [2, 3]

sub[0] = 99

fmt.Println(original) // [1 99 3 4 5] — original changed!

We modified sub, but original changed too. No pointer was explicitly shared. This is not a bug in Go — it is the feature working exactly as designed. The surprise comes from not knowing what a slice is at the memory level.

The Three-Word Slice Header

A slice value is a struct with three fields:

graph LR
    A["slice header"] --- B["ptr *T\n(pointer to array)"] --- C["len int\n(visible length)"] --- D["cap int\n(total capacity)"]
  • ptr — pointer to the first element visible through this slice.
  • len — how many elements are accessible via indexing.
  • cap — how many elements exist in the backing array from ptr onward.

When you write sub := original[1:3], Go creates a new three-word header. ptr advances by one element, len is set to 2, and cap is set to 4 (the remaining room in the original backing array). Both original and sub point into the same array:

graph LR
    subgraph original["original — ptr · len=5 · cap=5"]
        op["ptr"]
    end
    subgraph sub["sub — ptr · len=2 · cap=4"]
        sp["ptr"]
    end
    subgraph array["backing array"]
        e0["[0] 1"] --- e1["[1] 2"] --- e2["[2] 3"] --- e3["[3] 4"] --- e4["[4] 5"]
    end
    op --> e0
    sp --> e1

Writing to sub[0] writes to index 1 of the shared array — which is also original[1].

The append Twist

The shared-backing-array problem gets more dangerous with append, because whether a write goes to the shared array or a new one depends on the current capacity — a runtime value you cannot always predict at the call site.

a := make([]int, 3, 6) // len=3, cap=6
a[0], a[1], a[2] = 1, 2, 3

b := a[:2] // shares the same backing array, cap=6

b = append(b, 99) // cap is 6, plenty of room — writes into a's memory!

fmt.Println(a) // [1 2 99] — a[2] was silently overwritten

Now change the capacity:

a := make([]int, 3, 3) // cap=3, exactly full
b := a[:2]
b = append(b, 99) // cap exceeded — Go allocates a new array for b
fmt.Println(a)    // [1 2 3] — a is untouched

The same code produces different behavior depending on whether cap > len at the moment of append. In production this often means the bug appears under load (when a backing array is reused) and vanishes in tests (when each allocation is fresh).

The Fix: The Full Slice Expression

Go provides a three-index slice expression that lets you set the cap of a sub-slice explicitly:

sub := original[low:high:max]

Setting max == high gives sub a capacity of zero extra room, so the first append on sub will always allocate a new backing array:

original := make([]int, 5, 8)
// ...fill original...

// Without protection: sub shares original's excess capacity
sub := original[1:3]       // cap = 7

// With protection: cap is clamped to len
sub := original[1:3:3]     // cap = 2 — any append copies

Use this whenever you return a sub-slice from a function or store it alongside the original, and you do not intend for writes to propagate back.

The Fix: Explicit copy

When you need a fully independent slice, copy is clearer and leaves no ambiguity:

src := []int{1, 2, 3, 4, 5}
dst := make([]int, 2)
copy(dst, src[1:3])
dst[0] = 99

fmt.Println(src) // [1 2 3 4 5] — untouched

copy always creates an independent slice. Prefer it over the three-index expression when you know you want a distinct copy, not just a capacity-limited view.

Where This Bites You in Practice

Returning sub-slices from parsers

func tokenize(input []byte) [][]byte {
    var tokens [][]byte
    // ... append sub-slices of input ...
    return tokens
}

Every token is a window into input. If the caller modifies any token, it corrupts the source buffer and potentially other tokens. Use copy when building the token list, or document clearly that the tokens alias the input.

Filtering in place

A common Go pattern filters a slice by reusing its backing array:

func filter(s []int, keep func(int) bool) []int {
    out := s[:0] // len=0, cap=len(s), same backing array
    for _, v := range s {
        if keep(v) {
            out = append(out, v)
        }
    }
    return out
}

This is efficient and correct if you never use s again after calling filter. If both s and the returned slice are live, the overwrite will corrupt s. This pattern is fine; just be deliberate about lifetime.

Growing a buffer pool

buf := pool.Get().([]byte)
chunk := buf[:n] // sub-slice for one request
pool.Put(buf)    // return the full buffer
// ...later, still holding chunk...
chunk[0] = 'X'  // writes into a buffer another goroutine is now using

If a pooled buffer is returned before all sub-slices are done with it, you have a data race. Always copy out of pooled buffers before returning them.

The Mental Model

A slice is a view, not a value. Two slices sharing a backing array are two windows onto the same memory. The only safe assumption is that any write through one is visible through the other.

The three questions to ask when you create a sub-slice:

  1. Could the original and the sub-slice both be live at the same time?
  2. Could either be appended to while the other is held?
  3. Is mutation through one observable by a reader of the other?

If any answer is “yes” and that is not intentional, use the three-index expression or copy.

Summary

Operation Shares backing array? Safe concurrent mutation?
b := a[i:j] Yes No
b := a[i:j:j] Until first append No
copy(b, a[i:j]) No Yes
append([]T{}, a[i:j]...) No Yes

Slices are one of Go’s best-designed features precisely because they make zero-copy sub-slicing cheap. The cost is that you have to keep the backing array in your head — especially at API boundaries where the caller and callee may have very different expectations about ownership.