Arrays vs Slices

intermediate go slices arrays

This is THE classic Go interview topic. If we get one data structure question, it’s usually this.

Let’s start with the difference.

An array in Go has a fixed size baked into its type. [3]int and [4]int are literally different types. The size is part of the type.

A slice is a flexible, growable view over an array. It’s what we use 99% of the time.

var arr [3]int          // fixed-size array, length is always 3
arr[0] = 10

s := []int{1, 2, 3}     // slice, no size in the brackets
s = append(s, 4)        // slices can grow

Why arrays are weird: they’re value types

In simple language, when we pass an array to a function or assign it, Go copies the whole thing. Every element.

a := [3]int{1, 2, 3}
b := a          // b is a FULL copy
b[0] = 99
// a is still {1, 2, 3} — completely independent

That copying behavior surprises people. This is why we rarely use arrays directly. We use slices.

What a slice actually is

In simple language, a slice is a tiny struct with three fields:

  • ptr — a pointer to where the data lives (the backing array)
  • len — how many elements we can currently see
  • cap — how many elements fit before we run out of room
slice header
ptr  →
len = 2
cap = 4
backing array (cap = 4)
10
20
·
·
solid = visible (len), dashed = spare (cap)

make and slicing

make lets us pre-build a slice with a length and an optional capacity.

s := make([]int, 2, 5)  // len 2, cap 5 — two zero-value ints, room for 5
fmt.Println(len(s), cap(s)) // 2 5

nums := []int{10, 20, 30, 40}
sub := nums[1:3]        // a view: elements at index 1 and 2 → {20, 30}
fmt.Println(sub)        // [20 30]

The key thing: nums[1:3] does NOT copy. sub points into the same backing array as nums. Think of it like a window onto the same data.

How append grows: capacity doubling

When we append and there’s spare capacity, Go writes into the existing backing array. Fast.

When we run out of capacity, Go allocates a new, bigger backing array (roughly doubling for small slices), copies everything over, and returns a slice pointing to the new array.

s := make([]int, 0, 2)
fmt.Println(len(s), cap(s)) // 0 2
s = append(s, 1, 2)
fmt.Println(len(s), cap(s)) // 2 2 — full
s = append(s, 3)
fmt.Println(len(s), cap(s)) // 3 4 — capacity grew (doubled)

This is why we always write s = append(s, x) — append may return a different slice.

The big gotcha: shared backing array

This trips up almost everyone. When two slices share a backing array, appending into the spare capacity of one can silently overwrite the other.

base := []int{1, 2, 3, 4, 5}
a := base[0:2]              // {1, 2}, but cap is 5 (sees the rest)
a = append(a, 99)          // writes into base's slot at index 2!
fmt.Println(base)          // [1 2 99 4 5] — base got mutated!

We appended to a, but because a still had spare capacity inside base, the append clobbered base[2]. If we want a safe, independent copy, we use a full slice expression base[0:2:2] (caps the capacity) or just copy into a fresh slice.

Interview soundbite

An array has its size fixed in its type and copies by value; a slice is a lightweight header (pointer, length, capacity) that views a backing array. The classic gotcha is that slicing and appending can share that backing array, so an append into spare capacity can mutate another slice — append may also reallocate and double the capacity, which is why we always reassign the result.