Slices versus Arrays in Go

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Go 1.24

Most people treat arrays and slices as the same thing until they hit “I changed one and the other changed too”. The explanation starts with the memory model.

Arrays: the length is part of the type

var a [3]int          // type [3]int
var b [4]int          // type [4]int — a different type

// b = a  // compile error: cannot use a ([3]int) as [4]int

An array is a value: assignment and parameter passing copy the whole thing.

func modify(arr [3]int) {
	arr[0] = 999
}

func main() {
	a := [3]int{1, 2, 3}
	modify(a)
	fmt.Println(a)   // [1 2 3] — the caller's array is untouched
}

Because the length lives in the type, arrays are rarely the right container: [3]int and [4]int are incompatible, and passing a large array copies all of it. Use arrays when the size is genuinely part of the contract — hashes, matrices with fixed dimensions, wire formats.

Slices: a descriptor

A slice is a three-word header: a pointer to a backing array, a length and a capacity. Copying a slice copies the header, not the data.

nums := []int{1, 2, 3}
fmt.Println(len(nums), cap(nums))   // 3 3

Slicing an existing slice produces a new header over the same backing array:

nums := []int{1, 2, 3, 4, 5}
part := nums[1:3]      // [2 3], len=2, cap=4
part[0] = 99
fmt.Println(nums)      // [1 99 3 4 5]

cap(part) is 4, not 2: the capacity counts from the start of the window to the end of the backing array. That is why append on part can write into nums — and it is the source of the aliasing surprises below.

When you need independence, copy explicitly:

independent := make([]int, len(part))
copy(independent, part)

Or, since Go 1.21, use the generic helper:

independent := slices.Clone(part)

append returns a new header, so s = append(s, x) is the required form — appending without assigning the result is a bug the compiler will not catch.

append and capacity

append writes into the backing array while capacity allows, and allocates a fresh array otherwise:

s := make([]int, 0, 2)   // len=0, cap=2
s = append(s, 1, 2)
fmt.Println(len(s), cap(s))   // 2 2

s = append(s, 3)              // must grow
fmt.Println(len(s), cap(s))   // 3 4

Growth is amortised: roughly double for small slices, closer to 1.25× once they get large. The exact factor is an implementation detail — do not write logic that depends on cap after growth.

The leaky part is that a growing append may silently overwrite data visible through another slice:

base := []int{1, 2, 3}
a := base[:2]        // len=2, cap=3
b := append(a, 99)   // capacity available: writes into base[2]
fmt.Println(base)    // [1 2 99] — the caller's slice changed
fmt.Println(b)       // [1 2 99]

a itself still reports [1 2] because its length is 2, but the element beyond its length was overwritten. Bugs like this surface far away from the append.

Never assume the original slice is unchanged after append. If you need a guaranteed copy, slices.Clone first, or pre-allocate with make([]T, len(src), cap) and copy.

A full slice expression clamps the capacity so later appends are forced to allocate:

a := base[:2:2]   // len=2, cap=2 — append cannot touch base

nil slices

var s []int          // nil slice, len=0, cap=0
fmt.Println(s == nil, len(s))   // true 0
s = append(s, 1)                // legal

A nil slice is a valid, empty slice: len, range and append all work. You only need make when you are going to write by index:

s := make([]int, 3)   // [0 0 0], len=3
s[0] = 1

The rule of thumb: var s []T for “append as I go”, make([]T, n) for “fill by position”. Note that this differs from maps, where the nil value panics on write.

The classic bug: taking the address of an element

words := []string{"a", "b", "c"}
ptrs := make([]*string, 0, len(words))

for _, w := range words {
	ptrs = append(ptrs, &w)   // address of the loop copy
}

Before Go 1.22 the loop variable was shared across iterations, so all three pointers were identical and printed c c c. From Go 1.22 each iteration gets a fresh w, so this prints a b c — but the pointers still refer to copies, not the slice elements, and writing through them will not change words.

When you want the elements themselves, take their addresses:

for i := range words {
	ptrs = append(ptrs, &words[i])
}

Same reasoning applies to for _, v := range structs when v is a large struct: the copy is allocated every iteration. Iterate by index when it matters.

Summary

  • Arrays are values with the length in their type; slices are descriptors. Reach for slices.
  • Re-slicing shares the backing array; copy with copy or slices.Clone for independence.
  • append may write in place or reallocate — never rely on either outcome.
  • nil slices accept append; only indexed writes need make.
  • range gives copies; use the index when you need to address the real element.

Next: maps and structs — lookup tables and the value types you model your domain with.