DEV Community

Sylvester Asare Sarpong
Sylvester Asare Sarpong

Posted on

Shooting Yourself in the Foot with Slices in Go

Go slices are one of the language's most powerful features and one of its most dangerous. They look simple on the surface, but hide a set of behaviours that have caught out engineers at every level. This article walks through exactly what makes them tricky, using concrete examples to show where the traps are.


What a Slice Actually Is

Before anything else, it helps to understand what Go is actually doing under the hood when you create a slice. A slice is not an array it's a small struct with three fields:

type slice struct {
    array unsafe.Pointer  // pointer to the backing array
    len   int             // number of accessible elements
    cap   int             // total allocated space
}
Enter fullscreen mode Exit fullscreen mode

When you write []int{1, 34, 5, 6, 6, 7, 22, 1235}, Go allocates an array in memory and hands you a slice header pointing to it. When you write arr[1:4], Go doesn't copy anything it creates a new slice header pointing into the same array, at a different offset. This is O(1) and zero allocation, which is the whole point.

But it means two slice variables can silently share the same memory, and that's where the foot-shooting begins.


The Basic Trap: Slicing Shares Memory

arr := []int{1, 34, 5, 6, 6, 7, 22, 1235}
s := arr[1:4]

s[0] = 6000

fmt.Println(arr) // [1 6000 5 6 6 7 22 1235] arr[1] is mutated!
fmt.Println(s)   // [6000 5 6]
Enter fullscreen mode Exit fullscreen mode

s looks like its own slice. It prints independently. But when you modify s[0], you've modified arr[1]. They share the same backing array, and Go gives you no visual signal that this is the case. Both variables are typed []int. There's nothing at the call site that warns you.

This matters most when slices are passed around between functions. A function receiving []int has no way of knowing whether it shares memory with something else the caller cares about.


Three-Index Slicing: Controlling Capacity

Go provides a three-index slice expression for finer control:

arr[low : high : max]
Enter fullscreen mode Exit fullscreen mode

The third index, max, sets the capacity of the resulting slice:

arr := []int{1, 34, 5, 6, 6, 7, 22, 1235}
s := arr[1:4:5]

fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 4  (max - low = 5 - 1)
fmt.Println(s)      // [34 5 6]
Enter fullscreen mode Exit fullscreen mode

Without the third index, cap extends to the end of the original array. With it, you're limiting how far the slice can reach. This matters during append, as we'll see but it does not protect against direct index modification, which remains dangerous regardless.


The Append Trap: Capacity Determines Your Fate

Here's where things get genuinely surprising. The behaviour of append depends entirely on whether the slice has room to grow.

When capacity is available, append writes into the existing backing array and returns a slice still sharing the original memory:

arr := []int{1, 34, 5, 6, 6, 7, 22, 1235}
s := arr[1:4]        // len=3, cap=7

s = append(s, 99)   // fits within cap writes into arr[4]

fmt.Println(arr)     // [1 34 5 6 99 7 22 1235] arr[4] silently overwritten!
Enter fullscreen mode Exit fullscreen mode

This is the silent mutation problem. You called append on your slice. It looked like a normal operation. But it reached into the original array and clobbered a value you might have still needed.

When capacity is exceeded, Go allocates a brand new backing array, copies the data, and the new slice is fully independent:

arr := []int{1, 34, 5, 6, 6, 7, 22, 1235}
s := arr[1:4:4]      // len=3, cap=3 no room to grow

s = append(s, 991, 3, 4)  // exceeds cap → new allocation, link to arr is broken

fmt.Println(s)       // [34 5 6 991 3 4]
fmt.Println(arr)     // [1 34 5 6 6 7 22 1235] untouched
Enter fullscreen mode Exit fullscreen mode

Once a reallocation happens, s owns its own memory. Mutations to s no longer affect arr at all.


The Subtlest Trap: History Changes Behaviour

This is where it gets really counterintuitive. Consider the full sequence from the example above:

arr := []int{1, 34, 5, 6, 6, 7, 22, 1235}
s := arr[1:4:4]

s = append(s, 991, 3, 4)   // cap exceeded → s gets new backing array

fmt.Println("s:", s)                          // [34 5 6 991 3 4]
fmt.Println("original after append:", arr)    // [1 34 5 6 6 7 22 1235] clean

s[0] = 6000

fmt.Println("original after mutation:", arr)  // [1 34 5 6 6 7 22 1235] still clean!
fmt.Println("s:", s)                          // [6000 5 6 991 3 4]
Enter fullscreen mode Exit fullscreen mode

arr is untouched after s[0] = 6000. But this is not because the three-index cap protects against direct mutation it doesn't. It's because the earlier append already severed the connection. s had been reassigned to a new backing array before the direct assignment ever ran.

If you had written s[0] = 6000 before the append, you would have mutated arr[1]. The safety here is an artifact of operation order, not a language guarantee. This is the kind of thing that produces bugs that are extremely hard to reproduce.


The Capacity Window: How Much Damage Can Be Done

The third index in a slice expression controls the size of the "shared mutation window" how many elements can be appended before a reallocation is forced:

// Without third index wide window
s2 := arr[1:4]          // cap = 7
append(s2, 1, 2, 3, 4)  // all four fit silently mutates arr[4], arr[5], arr[6], arr[7]

// With third index narrow window
s := arr[1:4:4]         // cap = 3
append(s, 1)            // immediately exceeds cap → new allocation
Enter fullscreen mode Exit fullscreen mode

Setting max close to high forces an early reallocation, limiting the number of appends that can silently overwrite the original. But it's a mitigation, not a fix. One append that fits still mutates.


The Real Fix: copy

If you need a slice that is genuinely independent from its origin, copy is the only reliable answer:

arr := []int{1, 34, 5, 6, 6, 7, 22, 1235}

s := make([]int, 3)
copy(s, arr[1:4])

s[0] = 6000
append(s, 99, 100, 101)

fmt.Println(arr) // [1 34 5 6 6 7 22 1235] completely untouched
fmt.Println(s)   // [6000 5 6]
Enter fullscreen mode Exit fullscreen mode

copy allocates separate memory and duplicates the values. After this, s and arr are fully decoupled regardless of what operations follow.


Why Go Works This Way

This isn't an oversight. It's a deliberate performance tradeoff.

Slicing is O(1) and zero-allocation by design. In high-throughput systems parsing network packets, processing large byte buffers, reading files avoiding copies is a meaningful win. Go's authors made the call that the programmer should bear the cognitive burden of tracking memory sharing, rather than paying a runtime cost to eliminate it.

Other languages took different positions:

  • Rust makes aliasing and ownership explicit at the type system level. The compiler rejects unsafe sharing before the program ever runs. You cannot have this class of bug in safe Rust.
  • Python and Java use reference semantics with garbage collection you're always dealing with references, and the model, while less performant, is at least consistent.

Go's approach gives you performance without safety rails. The community guidance that emerged is simple: if a function receives a slice it shouldn't mutate, either document that contract clearly or copy defensively at the boundary.


What to Take Away

Slices in Go are not independent data structures. They are views into a backing array, and multiple slices can share the same view without any indication at the type level. The three behaviours to keep in your head at all times:

  1. Direct index assignment always writes through to the backing array. Cap doesn't change this.
  2. Append mutates the backing array if capacity is available. It only becomes safe after a reallocation.
  3. Reallocation severs the link. But whether reallocation happens depends on the slice's history how much cap was available before any appends ran.

When in doubt: copy. It's slightly more expensive and always correct.

Top comments (0)