Skip to content
CalliCoder

Introduction to Slices in Golang

Published Updated Golang 13 min read

Length against capacity, why append sometimes mutates the array you sliced from, the three-index expression that prevents it, and the small slice that keeps a large array alive.

A slice is a three-word header: a pointer to an array, a length and a capacity. Almost every surprising thing a slice does follows from that header being copied while the array it points at is not.

Written against Go 1.22.

Declaring and creating

var a []int                      // nil slice: len 0, cap 0, no array
b := []int{}                     // empty, non-nil
c := []int{1, 2, 3}              // literal, len 3, cap 3
d := make([]int, 5)              // len 5, cap 5, all zero
e := make([]int, 0, 10)          // len 0, cap 10 — room for ten appends
f := [5]int{1, 2, 3, 4, 5}[1:3]  // from an array

The make([]T, 0, n) form is the one to reach for when you know roughly how many elements are coming. It avoids reallocation on the way to n.

A nil slice is usable. Appending to it works, len is 0, and ranging over it does nothing:

var tags []string
tags = append(tags, "go")     // fine — no initialisation needed

The one place nil and empty differ is encoding:

var nilSlice []int
emptySlice := []int{}

json.Marshal(nilSlice)     // null
json.Marshal(emptySlice)   // []

If an API contract says “always an array”, initialise with []int{}. Otherwise prefer nil. It is the idiomatic zero value and needs no allocation.

Length and capacity

s := make([]int, 3, 8)
fmt.Println(len(s), cap(s))     // 3 8

len is how many elements you can index. cap is how many the backing array can hold from the slice’s start before it must be reallocated. Indexing past len panics even when capacity remains:

s := make([]int, 3, 8)
_ = s[5]        // panic: index out of range [5] with length 3
s = s[:6]       // re-slice within capacity: now len 6, no allocation
_ = s[5]        // fine, value 0

Slicing an existing slice can reach into its capacity:

a := []int{1, 2, 3, 4, 5}
b := a[1:3]
fmt.Println(b, len(b), cap(b))     // [2 3] 2 4

cap(b) is 4 because b starts at index 1 of a five-element array, so four slots remain to its right. That number is what decides the next section.

The append trap

This is the one to understand properly, because the compiler will not warn you and the symptom appears in unrelated code.

a := []int{1, 2, 3, 4, 5}
b := a[:2]                    // len 2, cap 5 — same array as a
b = append(b, 99)

fmt.Println(b)                // [1 2 99]
fmt.Println(a)                // [1 2 99 4 5]   <- a changed

append had spare capacity, so it wrote 99 into the existing array at index 2, which a also sees. No copy was made because none was needed.

Now the same code where capacity happens to be exhausted:

a := []int{1, 2}
b := a[:2]                    // len 2, cap 2
b = append(b, 99)             // no room: new array allocated, contents copied

fmt.Println(b)                // [1 2 99]
fmt.Println(a)                // [1 2]          <- a unchanged

So whether append mutates the original depends on capacity, which usually depends on data. That is the worst possible failure mode: correct in a test with three elements, wrong in production with three hundred.

The fix is the three-index slice expression, which caps the capacity explicitly:

a := []int{1, 2, 3, 4, 5}
b := a[0:2:2]                 // low:high:max — len 2, cap 2
b = append(b, 99)             // cap exhausted, so a copy is forced

fmt.Println(a)                // [1 2 3 4 5]   <- safe

a[low:high:max] sets capacity to max - low. When you hand a sub-slice to code that might append to it, cap it. Or copy, see below.

Never rely on growth policy. Go roughly doubles small slices and grows large ones more conservatively, but the exact factor is an implementation detail that has changed between releases. Code whose correctness depends on cap after an append is broken code.

copy

src := []int{1, 2, 3}
dst := make([]int, len(src))
n := copy(dst, src)           // n == 3, dst is independent

copy returns the number of elements copied, which is min(len(dst), len(src)), so a dst that is too short silently copies a prefix. Size it from len(src), and if you want a clone:

clone := slices.Clone(src)    // Go 1.21+, one line, correct length

copy also works from a string into []byte, and handles overlapping slices correctly, which is why it is safe for shifting elements within one slice.

Removing an element

// order does not matter: swap with the last and truncate — O(1)
s[i] = s[len(s)-1]
s = s[:len(s)-1]

// order matters: shift the tail left — O(n)
s = append(s[:i], s[i+1:]...)

// Go 1.21+, clearer and handles the tail correctly
s = slices.Delete(s, i, i+1)

The append(s[:i], s[i+1:]...) idiom has a subtlety worth knowing when the elements are pointers or contain them, after shifting, the final slot still holds the last element’s old value, and because it is inside the capacity it stays reachable. That keeps an object alive that you believe you removed. slices.Delete zeroes the vacated tail; if you are writing it by hand, do the same:

copy(s[i:], s[i+1:])
s[len(s)-1] = nil        // release the reference
s = s[:len(s)-1]

A small slice can keep a large array alive

func firstLine(data []byte) []byte {
    i := bytes.IndexByte(data, '\n')
    return data[:i]          // shares the whole array
}

Return a 40-byte slice of a 10 MB buffer and the garbage collector cannot free the 10 MB, because the slice header still points into it. Nothing about the returned value suggests this.

When a small result outlives a large source, copy:

func firstLine(data []byte) []byte {
    i := bytes.IndexByte(data, '\n')
    return bytes.Clone(data[:i])     // independent, releases the buffer
}

Slices are passed by value, and that is confusing

The header is copied; the array is not. So a function can modify existing elements of the caller’s slice, and cannot change its length:

func setFirst(s []int) {
    s[0] = 99                // caller sees this
}

func addOne(s []int) {
    s = append(s, 1)         // caller does NOT see this
}

func addOneProperly(s []int) []int {
    return append(s, 1)      // return it
}

append may reallocate and in any case updates the local copy of the header, so the caller’s len is unchanged. This is why every function that grows a slice returns it, and why s = append(s, x) is written with the assignment every time.

Two dimensions

grid := make([][]int, rows)
for i := range grid {
    grid[i] = make([]int, cols)     // each row separately
}

There is no make([][]int, rows, cols). For a fixed-size rectangle, one backing array with index arithmetic is faster and has better locality:

flat := make([]int, rows*cols)
at := func(r, c int) int { return flat[r*cols+c] }

The slices package

Go 1.21 added generic helpers that replace a lot of hand-written loops:

slices.Contains(s, 3)
slices.Index(s, 3)
slices.Sort(s)
slices.SortFunc(people, func(a, b Person) int { return cmp.Compare(a.Age, b.Age) })
slices.Reverse(s)
slices.Equal(a, b)
slices.Max(s)
slices.Clone(s)
slices.Delete(s, 1, 3)
slices.BinarySearch(sorted, 3)

SortFunc takes a three-way comparison returning negative, zero or positive, cmp.Compare is the usual way to produce one.

Frequently asked questions

What is the difference between length and capacity?

len is the number of elements you can index; cap is how many the backing array can hold from the slice’s start. Re-slicing up to cap allocates nothing.

Why did appending to one slice change another?

They share a backing array and append had spare capacity, so it wrote in place. Use the three-index form a[low:high:max] to cap capacity, or copy.

When does append allocate a new array?

Only when capacity is exhausted. Whether that happens depends on the data, which is why an aliasing bug can pass every small test.

What is a[1:3:3] for?

The third index sets capacity. It forces the next append to allocate rather than write into the original array.

Is a nil slice usable?

Yes, len is 0, ranging does nothing, and append works. The only practical difference from an empty slice is that it marshals to null rather than [].

Why does my function’s append not affect the caller?

The slice header is passed by value, so append updates the local copy. Return the slice and assign it.

How do I copy a slice properly?

slices.Clone(src), or make at len(src) then copy. copy transfers only min(len(dst), len(src)) elements, so an undersized destination silently truncates.

Why is memory not being freed after I sliced a small piece out?

The slice header still points into the original array, keeping all of it reachable. Clone the small piece so the large buffer can be collected.

How do I remove an element?

slices.Delete(s, i, i+1). By hand, shift with copy, zero the vacated tail if elements hold references, then truncate.

Can I rely on how much append grows a slice?

No. The growth factor is an implementation detail and has changed between Go releases.

Where should I go next?

Maps is the other reference-type built-in with its own sharp edges, and packages covers how to organise the code using them.