When I started learning Go, all I knew about slices was that they were a kind of dynamic array, and that was good enough for me. But after a certain amount of time using them, I learned how important it is to truly understand slice mechanics.

A shallow understanding of slices can only get you so far. My goal with this article is to make Golang slices click for you once and for all.

So, let’s dive in!

Let’s start with arrays Link to heading

The problem with arrays in Go is that they are not very flexible. Unlike other languages, you cannot have a variable number of elements in an array. Arrays in Go are fixed, and their length is part of their type.

The array below can have only 6 integer elements. If you need to add more, you must create a new array of a different size. The same goes with elements type.

var myArray [6]int

You can bypass the type by using any like in the following example:

package main

import "fmt"

type dog struct {
    name string
}
type bird struct {
    name string
}

func main() {
    var a [3]any
    a[0] = 42
    a[1] = dog{name: "Lassie"}
    a[2] = bird{name: "Tweety"}

    fmt.Println(a)
}

but if you want a different length, you must allocate a new array with the new size.

Manually allocating new memory whenever you need one more element in a list is cumbersome. If people complain about err != nil imagine the number of complaints about that! Actually, there won’t be any people to complain because nobody will use such a language. We need variable size lists in any programming language.

So Golang designers came up with slices, which are built on fixed-size arrays to give a flexible, extensible data structure that allows you to have the same behavior as regular arrays from other programming languages.

The internal structure of a slice Link to heading

What do you see in your mind when you see a slice in your code? Let’s take a simple example:

s := []string{"a", "b", "c"}

What do you think of when you see this? Maybe you imagine something like this:

Image Alt

Well, you’re partially correct. Because your mental model should be closer to this:

Image Alt

A slice has three parts:

a pointer to an array that acts as the storage of the slice(backing array),

a length of the slice that defines the number of elements in the slice, and

a capacity, which is the maximum value the length can reach.

If we check the Go runtime code, we see indeed that a slice is represented as a struct with three fields:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

Remembering this structure will solve most of your issues when working with slices in Go and encountering tricky situations.

When I’m confused, a trick I use is to imagine passing a struct like this in my functions instead of an array.

It’s important to know that slices are described differently than arrays when we talk about them because a slice is a 3-field data structure, not just a contiguous list.

Now that we know how a slice is structured let’s build on this information and review a series of examples to solidify our mental model and learn how slices really work.

Declaring our first slices Link to heading

In Go, we can use the len() and cap() functions to obtain the length and capacity of a slice. Can you guess what this code will print?

package main

import "fmt"

func main() {
    var s1 []string
    s2 := []string{}
    s3 := make([]string, 0)

    fmt.Println(s1, len(s1), cap(s1), s1 == nil)
    fmt.Println(s2, len(s2), cap(s2), s2 == nil)
    fmt.Println(s3, len(s3), cap(s3), s3 == nil)
}

run in Playground

As I said, when we describe slices, we talk about three things: len, cap, and the array pointer. So, a slice can also be checked for nil, depending on whether it has a pointer to a backing array or not.

s1 is nil because that’s how var works in Go: it declares the zero-value of a type.

The zero value of an int is 0, the zero value of a bool is false, the zero value of a string is “” and so on.

And the zero value of a pointer? It’s nil because it doesn’t point to anything. That’s why a slice can be nil when its array pointer is nil.

The zero value of a slice is nil because the pointer of its backing array is nil without referencing any array.

s2 is not nil because that’s a literal initialization where an internal array is allocated for it, so the array pointer will not be nil pointing to an allocated zero-length array.

s3 is similar to s2 because it initializes a slice with a zero-length array and len and cap are 0.

If you think of slices as just lists, it can be confusing to hear the terms nil slice and empty slice. But remember: a slice has three fields. When someone talks about a nil slice, it refers to the array pointer. When someone talks about an empty slice, he refers to the number of elements denoted by len. Two separate things.

A nil slice can be empty, but an empty slice is not necessarily nil, as we saw in the above example.

Returning slices Link to heading

We saw the above three ways of initializing slices. So, if you have a function that returns a slice, what’s the idiomatic way of declaring it?

You can use the zero-value declaration when that function can return an empty list:

func evenNumbers(numbers []int) []int {
    var s []int // idiomatic way of declaring an empty slice

    for _, v := range numbers {
       if v%2 == 0 {
          s = append(s, v)
       }
    }

    // if the `for` loop is not be executed it's safe to return s
    return s
}

When you know the length in advance, you can use the make function to avoid any extra allocations:

func mapRecords(dbRecords []dbRecord) []user {
    users := make([]user, len(dbRecords))
    for k, v := range dbRecords {
       users[k] = v.toUser()
    }
    return users
}

This second option with make is also useful when you encode the return value to JSON. A nil slice encodes to null, while a slice constructed with make or []string{} encodes to the JSON array []). If you find yourself puzzled about why you have nulls in your JSON when you used slices, that would be the reason.

Checking for empty slices Link to heading

When you call a function that returns a slice, and you want to test if it’s empty, always use the len function instead of checking for nil.

See below how checking for nil doesn’t result in the intended effect:

package main

import "fmt"

func main() {
    numbers := []int{1}

    evens := evenNumbers(numbers)

    // correct way to check for an empty slice
    if len(evens) == 0 {
       fmt.Println("no even numbers found")
    }

    // don't do this when checking for empty slice.
    // There can be functions that return non-nil empty slices
    if evens == nil {
       fmt.Println("no even numbers found")
    }
}

func evenNumbers(numbers []int) []int {
    s := []int{} // emply slice literal. slice is empty, but not nil
    for _, v := range numbers {
       if v%2 == 0 {
          s = append(s, v)
       }
    }
    return s
}

run in Playground

Playing with len and cap Link to heading

Besides the var declaration and make function, we can also create a slice by slicing an array or an existing slice using the : operator. Let’s see it in action and try to guess what this code will print:

package main

import "fmt"

func main() {
    arr := [6]int{12, 43, 55, 35, 1, 99}

    s := arr[1:4]

    fmt.Println(s, len(s), cap(s))
}

run in Playground

The slicing operator takes all the elements in the [start:end) half-open interval. You can also express this half-open interval in your mind as: from the index start, take end — start number of elements.

Now, going back to our example: why is the cap of s 5? Let’s explain:

The initial array is used as the storage. When we create s from arr the variables look like this:

Image Alt

The capacity of s is 5 because it’s referencing arr as its backing array. So even though we sliced 3 elements from arr, there are still 2 more elements from the end of arr available to the slice.

Let’s create a new slice from s:

package main

import "fmt"

func main() {
    arr := [6]int{12, 43, 55, 35, 1, 99}

    s := arr[1:4]
    s2 := s[1:2]

    fmt.Println(s, len(s), cap(s))
    fmt.Println(s2, len(s2), cap(s2))
}

run in Playground

The variables look something like below. Because we sliced s2 from s it references the same arr array.

Image Alt

With this approach, we see how Go tries to minimize new memory allocations as much as possible. However, this design has some implications, which we will discuss next.

Let’s check the following example and see if you know what it prints:

package main

import "fmt"

func main() {
    arr := [6]int{12, 43, 55, 35, 1, 99}

    s := arr[1:4]

    fmt.Println(s, len(s), cap(s)) // [43 55 35] 3 5
    s[0] = 77

    // What will these lines print?
    fmt.Println(s)
    fmt.Println(arr)
}

run in Playground

Running the above example, we notice that s[0] has changed to 77 because we had the s[0] = 77 assignment. But after doing that, we notice that arr[1] is also equal to 77. Why is that?

This happens because the s array pointer points to arr. So when we did s[0] = 77 we just followed the pointer of the slice backing array(which is arr) and changed 43 to 77.

Image Alt

This can be error-prone. You might forget that s is referencing the same array as arr and you wouldn’t expect that changing something in s will change arr as well.

To avoid this issue, you can use the copy function:

package main

import "fmt"

func main() {
    arr := [...]int{12, 43, 55, 35, 1, 99}

    s := make([]int, 3)
    copy(s, arr[1:4])

    fmt.Println(s, len(s), cap(s)) // [43 55 35] 3 5
    s[0] = 77

    fmt.Println(s)   // [77 55 35]
    fmt.Println(arr) // [12 43 55 35 1 99] arr is not modified by s[0] = 77
}

run in Playground

What we did here: we created a new slice with make and then copied the elements from arr[1:4] into s. Drawing this 3-step process, it looks like this:

Image Alt

Because s doesn’t point to arr, but to a different array(allocated with make) changes in s will no longer be reflected in arr.

A thing to remember about copy is that destination has to have space for the elements you are copying:

package main

import "fmt"

func main() {
    s1 := []int{1, 2, 3}
    var s2 []int
    copy(s2, s1)

    fmt.Println(s2) // nothing gets copied to s2 because it was started empty

    s3 := make([]int, 2)
    copy(s3, s1)
    fmt.Println(s3) // s3 is [1 2] because it was initialized with 2 elements
}

As the documentation says: Copy returns the number of elements copied, which will be the minimum of len(src) and len(dst).

If you find yourself confused about which is the source and which is the destination in copy, remember that Go favors the destination to be the first parameter. If you have from-to copies/writes with a function from is usually the first parameter.

Now, let’s talk a bit more about length and capacity. Try to guess what this code will print:

package main

import "fmt"

func main() {
    arr := [...]int{12, 43, 55, 35, 1, 99}

    s := arr[1:4]

    fmt.Println(s, len(s), cap(s)) // [43 55 35] 3 5
    s[3] = 77

    // what will be printed here?
    fmt.Println(s)
    fmt.Println(arr)
}

run in Playground

I was a bit misleading on this example because the program never gets to print the last two lines. Instead, we get the following error:

panic: runtime error: index out of range [3] with length

Why is that? s has capacity 5; why can’t we access s[3]?

Here, we need to remember that length is the field that runs the show when accessing elements of the slices. The length defines the number of elements of the slice. So you can only access those elements and not more, even if you have extra internal capacity.

In our case, s has length 3 and contains [43 55 35] elements. Thus, we can only access the indexes 0, 1, and 2. Not 3.

Image Alt

Once you get a slice of a cake, you have access only to the berries from that slice. Bummer, I know :(

And it doesn’t matter how you create that slice:

package main

import "fmt"

func main() {
    s := make([]int, 0, 3)
    s[0] = 1
    fmt.Println(s, len(s), cap(s))
}
panic: runtime error: index out of range [0] with length 0

You might ask? What’s the point of cap if len is the one that runs the show? What’s the point of being able to set it in the make function if I don’t have access to those extra elements? These are valid questions, so let’s try to answer them.

The answer to the first : cap is used to know when Go needs to re-allocate a new backing array when using append, a function that we cover next.

Introducing append Link to heading

Adding new elements to a slice is done with the append function. Append is the function that gives us dynamic array functionality. So we can fix the previous example like this:

package main

import "fmt"

func main() {
    s := make([]int, 0, 3)
    s = append(s, 1) // we append 1 to s. no more panics
    fmt.Println(s, len(s), cap(s)) // what will this print?
}

run in Playground

Now, it’s essential to know that append does what it says: appends elements to an existing slice. Can you guess what this will print?

package main

import "fmt"

func main() {
    s := make([]int, 3)
    s = append(s, 42)

    fmt.Println(s)
}

run in Playground

When calling append for the first time on an existing slice, it doesn’t necessarily add an element to s[0]. The above example will output:

[0 0 0 42]

Because s was initialized to [0, 0, 0] with make([]int, 3) and then we appended 42 to that list. So be careful when you use append to add elements to a list: do you want to start from index 0 or append them to the rest of the list?

Back to our first question:

What’s the point of cap if len is the one that runs the show?

Well, we know that slices use an internal backing array. When we add elements with append we will eventually reach the end limit of that backing array. What will happen when we call append next?

package main

import "fmt"

func main() {
    s := make([]int, 0, 3)
    fmt.Println(s, len(s), cap(s)) // [] 0 3

    s = append(s, 1)
    fmt.Println(s, len(s), cap(s)) // [1] 1 3

    s = append(s, 1)
    fmt.Println(s, len(s), cap(s)) // [1, 1] 2, 3

    s = append(s, 1)
    fmt.Println(s, len(s), cap(s)) // [1, 1, 1] 3, 3

    // What will happen now?
    s = append(s, 1)
    fmt.Println(s, len(s), cap(s)) // ???
}

run in Playground

When we append elements to s at one point, len will be equal to cap.

In other words, there is no more space in the backing array. And here is where cap comes into play: when len == cap, a new array will be allocated with a size double the previous capacity, and the pointer of the slice will point to the new allocated array:

Image Alt

When append is called, and we reach len == cap, it will allocate a new array with double the capacity. But up to a point: after a certain number of elements, it will not double but grow with a lesser factor.

So this is the purpose of cap: to tell append when it should allocate a new array for the slice.

How is capacity useful Link to heading

Now we know that capacity is used internally to allocate a new backing array once len == cap. But can it be useful in other ways? This was our second question:

What’s the point of being able to set capacity in the make function if we don’t have access to those extra elements?

When you create a list from another one, you can set capacity so you don’t do any allocations.

Let’s see first how it is without setting the capacity:

package main

import "fmt"

func main() {
    s := make([]int, 0)
    for range 1000 {
       fmt.Println(len(s), cap(s)) // check the growth of
       s = append(s, 1)
    }
}

run in Playground

We will see how the cap(s) changes on some iterations. That’s when append will allocate a new array because the internal array has reached its limit:

0 0
1 1
2 2
3 4
4 4
5 8
6 8
7 8
8 8
9 16
10 16
11 16
12 16
13 16
14 16
15 16
16 16
17 32
18 32
19 32
20 32

If we know in advance the number of the inserted values, we can pre-allocate the internal array with the size of capacity, avoiding those extra allocations. We also set the length to 0 to make sure that when adding new elements with append, we start from index 0, not 1000:

package main

import "fmt"

func main() {
    s := make([]int, 0, 1000)
    for range 1000 {
       fmt.Println(len(s), cap(s))
       s = append(s, 1)
    }
}

run in Playground

Running the above example, we will notice that cap is always 1000, which shows that only an array of size 1000 has been allocated at the beginning and used throughout every iteration.

Make sure to use the return of append() Link to heading

The last point I want to make is an answer to a question on the Golang Insiders. You’re probably equipped by now with the right information to answer the question, but if not, no worries; I’ll explain it below.

Image Alt

In Go, everything is passed by value. If you pass a variable to a function, that function makes a copy of that variable and works on the copy.

We start with a slice of length 0 and capacity 2. So, the slice is empty, and its backing array has space for 2 elements.

When we call doSmth(s), a copy of s is being made, and the function will work on that copy. append will add an element to the copy and return the updated slice to copy. But s from main which is separate from its copy, will not reflect the result.

But don’t they use the same internal array? Yes, they do. After calling doSmth the situation will look like this:

Image Alt

But because s from main, still has length 0, there is no way to see the changes: s from main is and stays as an empty slice.

To be able to see the changes, the code should be changed to:

package main

import (
    "fmt"
)

func main() {
    s := make([]int, 0, 2)

    s = doSmth(s)

    fmt.Println(s) // this outputs [1]
}

func doSmth(s []int) []int {
    s = append(s, 1)
    return s
}

Now s will be updated to the return of doSmth which has a length of 1, thus displaying [1].

The end Link to heading

This was a long article, but I hope you solidified your knowledge about slices. If you still have questions, feel free to connect with me on Linkedin and ask any questions you would have.