Arrays and Slices in Go: How Slices Really Work
Chapter 01 of 04

Arrays and Slices in Go: How Slices Really Work

The slice header, shared backing arrays, append and capacity growth (Go 1.18+), and the classic interview puzzles explained step by step.

Sabbir Ahmmed5 min read1
On this page

Slices are the most used data structure in Go and the source of the most interview questions. Once you understand that a slice is just a small view onto an array, every tricky append puzzle becomes predictable. This article builds that mental model step by step and verifies every output on Go 1.27.

Arrays: fixed size, value semantics

An array's length is part of its type. [3]int and [4]int are different types.

go
1
2
3
var arr [2]int               // [0 0], zero valued
nums := [3]string{"I", "love", "Go"}
auto := [...]int{1, 2, 3}     // compiler counts: [3]int

Arrays are values. Assigning or passing one copies every element:

go
1
2
3
4
a := [3]int{1, 2, 3}
b := a   // full copy
b[0] = 100
fmt.Println(a, b) // [1 2 3] [100 2 3]

Because of the fixed size and copying, you rarely use arrays directly. You use slices, which are built on top of them.

What a slice really is

A slice is a tiny struct with three fields, often called the slice header:

go
1
2
3
4
5
type slice struct {
	ptr *T // pointer to the first element it can see in the backing array
	len int // how many elements it currently holds
	cap int // how many elements exist from ptr to the end of the backing array
}

Think of a pizza (the backing array) and a slice of it: the slice remembers where it starts, how many pieces it has, and how many pieces remain until the end of the pizza.

Slicing an existing array

a[low:high] includes low and excludes high.

go
1
2
3
4
5
6
7
arr := [6]string{"This", "is", "a", "go", "interview", "question"}

s := arr[1:4]
fmt.Println(s, len(s), cap(s)) // [is a go] 3 5

s2 := s[1:2]
fmt.Println(s2, len(s2), cap(s2)) // [a] 1 4
len(s) is 4 - 1 = 3.
cap(s) counts from index 1 to the end of the array: 6 - 1 = 5, not 4.
s2 starts one element later in the same array, so its capacity is 4.

You can even "re-extend" a slice up to its capacity and see elements that were outside its length:

go
1
fmt.Println(s[:cap(s)]) // [is a go interview question]
Important
s, s2 and arr all share one backing array. Writing s2[0] = "A" changes arr[2] and s[1] too. Slicing never copies data.

Creating slices

go
1
2
3
4
5
lit := []int{1, 2, 3, 4, 5}  // slice literal: len 5, cap 5
m := make([]int, 3)          // len 3, cap 3, values [0 0 0]
m2 := make([]int, 3, 5)      // len 3, cap 5
var nilSlice []int           // nil: ptr=nil, len 0, cap 0
empty := []int{}             // empty but not nil

A slice literal looks like an array literal without the size. Indexing beyond len panics even if capacity is larger:

go
1
2
m2 := make([]int, 3, 5)
m2[3] = 20 // panic: runtime error: index out of range [3] with length 3

To use the extra capacity you append, or reslice: m2 = m2[:4].

nilSlice == nil is true and empty == nil is false, but both have length 0, and append, len and range work on both. Prefer checking len(s) == 0 over s == nil.

append and capacity growth

append writes into the existing backing array if there is spare capacity. Otherwise it allocates a bigger array, copies the elements, and returns a slice pointing to the new array. That is why you must always use the result: s = append(s, v).

go
1
2
3
4
5
var x []int
for i := 1; i <= 3; i++ {
	x = append(x, i)
	fmt.Printf("append %d -> len=%d cap=%d\n", i, len(x), cap(x))
}
Output
1
2
3
append 1 -> len=1 cap=1
append 2 -> len=2 cap=2
append 3 -> len=3 cap=4

How much does it grow? Many tutorials (and my old notes) say "double until 1024, then grow by 25%". That was the rule up to Go 1.17. Since Go 1.18 the runtime doubles small slices up to a threshold of 256 elements, then grows by roughly newcap += (newcap + 3*256) / 4, which smoothly moves from 2x towards 1.25x. The result is then rounded up to a memory allocator size class.

Appending 2,000 ints one by one on Go 1.27 produces these capacities:

Capacity growth on Go 1.27
1
4 8 16 32 64 128 256 512 848 1280 1792 2560

Doubling up to 512, then 848 (not 1024), then smaller steps. The exact numbers depend on the element size and Go version, so never write code that relies on them. If you know the final size, preallocate: make([]T, 0, n).

Interview puzzle 1: two slices, one array

go
1
2
3
4
5
6
7
8
9
10
var x []int
x = append(x, 1)
x = append(x, 2)
x = append(x, 3) // len 3, cap 4
y := x           // copies the header, shares the array
x = append(x, 4) // fits in cap: writes array[3] = 4
y = append(y, 5) // y has len 3, cap 4: writes array[3] = 5 (overwrites!)
x[0] = 10
fmt.Println(x)
fmt.Println(y)
Output
1
2
[10 2 3 5]
[10 2 3 5]

y := x copies only the header. Both point to the same array with spare room, so the second append overwrites the element the first one wrote. If x had been full (len == cap), append would have allocated a new array and they would have separated.

Interview puzzle 2: modifying a slice inside a function

go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func changeSlice(a []int) []int {
	a[0] = 10
	a = append(a, 11)
	return a
}

aa := []int{1, 2, 3, 4, 5} // len 5, cap 5
aa = append(aa, 6)         // full: new array, len 6, cap 10
aa = append(aa, 7)         // len 7, cap 10
bb := aa[4:]               // [5 6 7], len 3, cap 6
yy := changeSlice(bb)

fmt.Println(aa)       // [1 2 3 4 10 6 7]
fmt.Println(yy)       // [10 6 7 11]
fmt.Println(aa[0:8])  // [1 2 3 4 10 6 7 11]

Step by step:

1.bb shares aa's array starting at index 4.
2.a[0] = 10 writes to that shared array, so aa[4] becomes 10.
3.append(a, 11) has spare capacity, so it writes 11 at index 7 of the same array. aa still has length 7, so it does not show the 11...
4....but reslicing aa[0:8] (allowed, because cap(aa) is 10) reveals it.
Tip
Passing a slice to a function copies the header, not the data. The function can change existing elements (visible to the caller) but append may or may not be visible. Return the new slice and let the caller assign it, exactly like the built-in append.

Protecting a slice: the full slice expression

a[low:high:max] also limits capacity to max - low. With no spare capacity, the next append is forced to copy, so it cannot overwrite the original:

go
1
2
3
4
full := []int{1, 2, 3, 4, 5}
safe := full[1:3:3]    // [2 3], len 2, cap 2
safe = append(safe, 99) // must allocate a new array
fmt.Println(full, safe) // [1 2 3 4 5] [2 3 99]

To copy explicitly, use copy(dst, src) or slices.Clone(s) (Go 1.21+).

Memory gotcha: small slices of big arrays

A small slice keeps its entire backing array alive. If you read a 10 MB file and keep data[:10], all 10 MB stay in memory. Copy what you need instead: header := slices.Clone(data[:10]).

Key takeaways

A slice is {pointer, length, capacity} over a backing array.
Slicing and assigning share data; they never copy elements.
append writes in place when there is capacity, otherwise reallocates. Always use its return value.
Growth doubles up to 256 elements, then grows more slowly (Go 1.18+). Do not depend on exact numbers.
Use make([]T, 0, n) to preallocate, and s[a:b:b], copy or slices.Clone to avoid accidental sharing.
#Golang#Slices#Interview#Memory

Last updated on

Discussion0