Skip to content
C Codeloom
Go

Go Slices vs Arrays: A Deep Dive

Understand the real difference between arrays and slices in Go, how slice headers work, and how to avoid the classic aliasing and capacity surprises.

·4 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • How Go arrays differ from slices at the type level
  • What lives inside a slice header (pointer, len, cap)
  • How append, growth, and reallocation actually work
  • When slices alias the same backing array
  • Practical patterns to avoid hidden bugs

Prerequisites

  • Basic Go familiarity

What and Why

In Go, arrays and slices look almost identical at the surface. Both use []T style syntax and both index from zero. But they behave very differently. An array’s length is part of its type: [3]int and [4]int are different types, and assigning one to the other will not compile. A slice, written as []int, is a small header that points into a backing array. That distinction shapes how data is passed, copied, and mutated.

Understanding this is essential because slice aliasing, growth behavior, and copy semantics are responsible for a large share of Go bugs that show up in code review.

Mental Model

Picture an array as a sealed box of a fixed size. The size is stamped on the outside (the type). When you pass it to a function, Go copies the entire box.

A slice is a sticky note that references part of that box. The note contains three fields: a pointer to the first element, a length (how many elements are visible), and a capacity (how many elements exist beyond the start before reallocation is required). Multiple sticky notes can point into the same box, which is where aliasing surprises come from.

When you append and exceed capacity, Go allocates a new, larger backing array, copies the elements, and your slice now points somewhere else. Other slices that referenced the old array continue to see the old data.

Hands-on Example

arr := [3]int{1, 2, 3}
s := arr[:]        // slice over the array
s2 := s[:2]        // shares the backing array
s2[0] = 99         // mutates arr and s too

fmt.Println(arr)   // [99 2 3]

s3 := append(s2, 7) // may or may not reallocate

Whether append reallocates depends on cap(s2). If there is room, the existing backing array is mutated. If not, a fresh array is allocated.


backing array: [ 99 | 2 | 3 ]
                 ^
                 |
 s   = { ptr -> [0], len=3, cap=3 }
 s2  = { ptr -> [0], len=2, cap=3 }
 s3  = { ptr -> [0], len=3, cap=3 }   (no realloc)

 After append that exceeds cap:
 new array: [ 99 | 2 | 7 | _ | _ | _ ]
 s3 -> new array, s and s2 still point to old
A slice header points into a backing array; multiple slices can alias it.

The diagram shows why two slices that “should be equal” can diverge after an append. The header copy is cheap; the backing array is shared until reallocation breaks the connection.

Common Pitfalls

The first pitfall is assuming append always returns a new array. It often does not, so callers that ignored the return value sometimes see mutations propagate unexpectedly.

The second pitfall is slicing a large array and holding the small slice forever. Because the slice keeps a pointer to the backing array, the garbage collector cannot free the large array. Use append([]T(nil), small...) to copy into a fresh, small backing array.

A third trap is passing arrays expecting reference semantics. func f(a [1000]int) copies a thousand integers on every call. Use a slice or a pointer to the array instead.

Finally, ranging over a slice and taking the address of the loop variable gives you the same address each iteration. Capture by value or by index.

Practical Tips

Prefer slices for almost all function parameters and return types. Reserve arrays for fixed-size, value-semantics cases such as hash digests ([32]byte).

Pre-size slices when you know the final length: make([]T, 0, n) avoids repeated reallocation during append.

When you want to defensively decouple two slices, use copy or slices.Clone. Do not rely on subtle capacity tricks unless your team understands them.

Use cap() along with len() while debugging. A surprising number of “why did this mutate?” bugs become obvious once you see capacity.

Wrap-up

Arrays are fixed-size value types. Slices are small headers over a shared backing array, with separate length and capacity. Once you internalize the slice header model, append’s behavior, aliasing surprises, and memory retention bugs all become predictable rather than mysterious. Reach for slices by default, pre-size when you can, and clone when you need isolation.