Visual Guide to Slices in Go

Visual Guide to Slices in Go

tutorial
18 min read

We use slices everywhere in Go. With maps, they are fundamental data types we use to store data. Today we will dive into the internals and see how slices work under the hood.

sazak-explain

At the end of the article, you will find exercises to test your knowledge. Enjoy!

What is a slice?

In Go, slice is an abstraction over arrays. It consists of a pointer to the backing array, the length of the slice, and its capacity. The length is the number of elements in the slice, while the capacity is the number of elements in the underlying array.

Here's how a slice is defined in Go runtime:

go/src/runtime/slice.go1type slice struct {
2	array unsafe.Pointer
3	len   int
4	cap   int
5}

The array field is a pointer to the (first element of the) backing array. The len field is the length of the slice, denoting the number of items in the slice, and the cap field is the capacity of the slice. Capacity stores the number of elements in the backing array, and it is the maximum number of elements the slice can hold without reallocating the backing array.

There are multiple ways to create a slice, and some of them differ in how they set the length and capacity of the slice. Let's say we have the following slice definition:

1s1 := []int32{1, 2, 3, 4, 5}

This creates an int32 slice s1 with some initial values. The length of the slice is 5, as you can see from the number of elements in the slice definition. The capacity is also set to 5 by default (we did not specify a different capacity), and the backing array is filled with the given values.

Below is how the backing array looks like in memory. Numbers inside the cells represent values of items in the array, and the gray cells represent the memory that does not belong to the slice. See how both length and capacity is 5, and slice points (ptr = 0x0100) at the address of the first element of the array (0x0100).

s1 []int32len: 5, cap: 5, ptr: 0x01000x0100123450x0120usedemptyunavailable

make-ing of a slice

We can specify different lengths and capacities for a slice by using the make built-in function. The make function accepts two to three arguments for slice allocation: the type of the slice, the length, and the capacity. If the capacity is not given, it defaults to the length.

1s2 := make([]int, 5, 10)

The code above allocates a 10-capacity array and creates a slice s2, which points to this array with a length of 5. The capacity of the slice is also set to 10.

make also fills the array with the zero values1 of the item type. In this case, the zero value of int is 0.

s2 []intlen: 5, cap: 10, ptr: 0x12340000000000usedemptyunavailable

sazak-explain

Notice that all items of the backing array are filled with zero values, not only the len items of the slice. From now on I won't show the zeroes in the unused parts of the backing array to keep the visuals more readable.

If we want to populate this slice with some values, we can set specific indexes:

1for i := range s2 {
2    s2[i] = i * 2
3}

This will set the values of the slice to 0, 2, 4, 6, 8, by using range2 to iterate over the indexes of the slice.

s2 []intlen: 5, cap: 10, ptr: 0x123402468

Notice how the slice is still pointing to the same underlying array, but the initial values are changed. Also the length and capacity of the slice are still the same.

beaver-question

Why didn't range iterate over all indexes within the capacity (10) of the array?

Good question. Let's see what happens if we try to set a value to an index that is out of the slice's length?

1s2[8] = 16

If we run the assignment above, Go runtime will panic with the message below:

1panic: runtime error: index out of range [8] with length 5

The capacity is not the space Go gives us to use, it's the maximum number of the elements slice can hold without reallocating the backing array. If we try to access or set a value to an index that is out of the slice's length, Go will panic.

Okay, so what if we add more elements by simply appending them to the slice?

1s2 = append(s2, []int{10, 12, 14, 16, 18}...)

The built-in function append works by pushing these items to the end of the slice, starting by the len-th item. Both the length and the capacity of the slice are 10 now.

s2 []intlen: 10, cap: 10024681012141618

Growing the slice

What do you think will happen if we add one more item to the end of the slice now, as it's capacity is already full?

If we have a full slice (len == cap), then Go runtime will reallocate the backing array, copy the existing elements to the new array, and append the new element.

This was what I meant by "slices are abstraction over arrays". Go abstracts the memory management from the developer so you don't need to think if the backing array is full, or how much of an extra capacity should I allocate every time. In C for example, you would need to use calloc or realloc3 to handle the reallocation of the memory, manually.

The new capacity will be double the previous capacity until the slice reaches a certain capacity threshold. After that, the capacity will increase monotonically with different growth factors.

You can see in other (older) resources that the capacity increases by 25% after 1024 items, but in 2021 the growth formula was changed to be smoother.

Below is the theoretical4 growth factors by certain capacity thresholds:

Starting CapacityGrowth Factor
2562.0
5121.63
10241.44
20481.35
40961.30

So if we add one more item at the end of the slice s2 we had, the append function will allocate a new array with a capacity of 20, copy the existing elements to the new array, and append the new element.

1s2 = append(s2, 20)

old arraylen: 10, cap: 10, ptr: 0x1234024681012141618

s2 []intlen: 11, cap: 20, ptr: 0x202002468101214161820usedemptyunavailable

Notice how s2 now points to a different backing array with a capacity of 20. The old array at memory address 0x1234 will be garbage collected by the Go runtime5 if no other slice points to it (references it).

sazak-explain

Keep in mind that the memory addresses I use in examples are just for demonstration purposes. In reality, the memory addresses are longer 32-bit or 64-bit integers and can change between runs.

A note on append function

In many places you will see the append function used with the same slice as the first argument, like sl = append(sl, "hello").

This is because when the slice given exceeds the capacity with the given new elements and append needs to internally reallocate a new backing array, the new slice structure pointing to the new array will be returned from the function. The slice given in the first argument, however, will not be updated in-place.

That's why if you want the update to be reflected to the slice, you need to assign the result of the append function to the slice, as how append is used for most of the time.

It's good to know how using different variables for the result of the append function can affect the slices. Consider the following example:

1dogs := []string{"bulldog", "poodle", "beagle"}
2moreDogs := append(dogs, "husky")
3moreAndMoreDogs := append(moreDogs, "shiba inu")

Before the append, dogs has both length and capacity of 3, and points to the backing array with the elements "bulldog", "poodle", and "beagle". As dogs is at full capacity, the first append call will return a new slice, pointing to a new backing array filled with the initial elements and the new element "husky", with a capacity of 6.

Now moreDogs points to the new backing array, and dogs still points to the old backing array. In the second append call, we would not expect another reallocation happening, as the moreDogs slice has enough capacity to hold the new element "shiba inu". So the moreAndMoreDogs slice will point to the same backing array as moreDogs.

Slicing

We can create a new slice from an existing slice by using the slicing expression. It's syntactically similar to what Python does with lists.

When we slice another slice, the new slice will point to the same backing array, but the length and capacity will be different, depending on the range we specify. The range is inclusive for the start index and exclusive for the end index.

1s1 := []byte{16, 32, 48, 64, 80}
2s2 := s1[1:3]
3
4fmt.Printf("s1: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1))
5fmt.Printf("s2: %v, len: %d, cap: %d\n", s2, len(s2), cap(s2))

The code above will output:

1s1: [16 32 48 64 80], len: 5, cap: 5
2s2: [32 48], len: 2, cap: 4

Length of s2 is 2, as the range [1:3] includes the elements at indexes 1 and 2. The capacity will be the capacity of s1 minus the start index, which is 4.

If we look at the pointers of the slices, we will see that they don't point to the same memory address:

1fmt.Printf("s1: %p\n", s1)
2fmt.Printf("s2: %p\n", s2)
1s1: 0x0010
2s2: 0x0011

Notice how the pointer of s2 is one byte ahead of the pointer of s1. This is because the slice s2 starts from the second element of the slice s1, and as each element in slice s1 is one byte long (byte), the second item is one byte ahead of the first item.

s1 []bytelen: 5, cap: 5, ptr: 0x00100x001016324864800x0018
s2 []bytelen: 2, cap: 4, ptr: 0x00110x001132480x0019

Let's play a little bit on our new slice s2. Can you guess the outcome of the following code?

1s2 = append(s2, 100, 101)

Remember that s2 points to the same backing array as s1, and has enough capacity (4) to hold two more elements. So the new slice s2 will be:

s2 []bytelen: 4, cap: 4, ptr: 0x00113248100101

And, as the slice s1 still points to the same backing array, it will look like the following in memory:

s1 []bytelen: 5, cap: 5, ptr: 0x0010163248100101

Notice how the slice s1 is also updated, as it points to the same backing array as s2.

Now that s2 is at full capacity, you know that if we append one more element to s2, the backing array will be reallocated, and the slice will point to a new array.

1s2 = append(s2, 102)

s2 []bytelen: 5, cap: 8, ptr: 0x00500x005032481001011020x0058usedemptyunavailable

s2 now points to a new backing array in a different memory address (0x0050), with a capacity of 8.

beaver-question-3

What will happen to s1 in this case?

Normally the old array would be garbage collected, but in this case, s1 still points to it, so it will not be garbage collected.

s1 []bytelen: 5, cap: 5, ptr: 0x00100x00101632481001010x0018usedemptyunavailable

See how appending to a slice which shares the same backing array with another slice can affect both slices, until one exceeds its capacity and it has a new reallocated backing array.

Specifying the capacity

Different slices using the same backing array may cause implicit problems sometimes. The new slice used in a function or in another part of the code appending new elements to the slice may cause the original slice to change its contents, which may not be the desired behavior.

beaver-question

How can I prevent this behavior then?

Go allows us to specify the capacity of the new slice by using a third argument in the slicing expression. This is useful when we want to limit the capacity of the new slice, to prevent the slice to change the contents out of the specified bounds of the backing array.

If we would like to create a new slice from s1 starting from the second element and ending at the third element, with a capacity of 2, we can do it like this:

1s3 := s1[1:3:3]

This will create a new slice s3 with a length of 2 (indexes 1 and 2), a capacity of 2 as specified, and it will point to the second item (index 1) of the same backing array as s1. Notice that the third argument is not the capacity of the new slice, but in which index the capacity should end.

Now if we append a to s3, as s3 is at capacity (len(s3) == cap(s3) == 2) it will cause a reallocation of the used parts of the backing array, but it will not affect the contents of s1.

1s1 = []byte{16, 32, 48, 64, 80}
2s3 := s1[1:3:3]
3s3 = append(s3, 200, 201)

s3 []bytelen: 4, cap: 8, ptr: 0x00600x006032482002010x0068
s1 []bytelen: 5, cap: 5, ptr: 0x00100x001016324864800x0018usedemptyunavailable

Notice how the ptr of s3 is different as it now points to a new backing array with length of 4 (2 old elements copied and two newly appended), and capacity of 4 (twice as the old capacity). The contents of s1 after the index 2 are not affected by the append operation on s3.

The whole code snippet is below:

go.dev/play/p/DrKX2QE24QP 1s1 := []byte{16, 32, 48, 64, 80}
 2s2 := s1[1:3]
 3
 4s2 = append(s2, 100, 101)
 5s2 = append(s2, 102)
 6
 7fmt.Printf("s1: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1))
 8fmt.Printf("s2: %v, len: %d, cap: %d\n\n", s2, len(s2), cap(s2))
 9
10s1 = []byte{16, 32, 48, 64, 80}
11s3 := s1[1:3:3]
12
13fmt.Printf("s3 initially: %v, len: %d, cap: %d\n\n", s3, len(s2), cap(s3))
14
15s3 = append(s3, 200, 201)
16
17fmt.Printf("s1: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1))
18fmt.Printf("s3: %v, len: %d, cap: %d\n", s3, len(s2), cap(s3))
1s1: [16 32 48 100 101], len: 5, cap: 5
2s2: [32 48 100 101 102], len: 5, cap: 8
3
4s3 initially: [32 48], len: 5, cap: 2
5
6s1: [16 32 48 64 80], len: 5, cap: 5
7s3: [32 48 200 201], len: 5, cap: 8

Specifying the slice capacity is an option we can use to limit the new slice's capacity and actions on the items of backing array, to prevent unexpected changes in the original slice.

Conclusion

Slices are a powerful abstraction in Go, allowing us to work with arrays in a more flexible way. Understanding how slices work internally helps us write more efficient and less error-prone code. I hope this article helped you gain insight on how slices work under the hood, and when to pay attention to implementation details to prevent unexpected behavior.

There will be more of these visual guides to Go internals in the future, and there may be a part two to this article.

If you have any questions or feedback, feel free to reach out to me on Twitter. Until next time, stay tuned.

Exercises

BeginnerRun this code inplay.go.dev

What is the length and capacity of slice s1 after the operations below?

1s1 := make([]int, 6, 8)
2
3for i := range s1 {
4    s1[i] = 2 * i + 1
5}
6
7s1 = append(s1, []int{100, 23, 0x5, -23}...)

BeginnerRun this code inplay.go.dev

What are the contents of the slice funcSl below?

1funcSl := make([]func(context.Context) error, 5, 10)

MediumRun this code inplay.go.dev

What are the lengths and capacities of slices s1, s2, and s3 after the operations below?

1s1 := make([]string, 2, 5)
2
3s1[0] = "A"
4s1[1] = "B"
5
6s2 := append(s1, "C")
7s3 := append(s2, "D")

GopherRun this code inplay.go.dev

What are the contents, length and capacities of slices s1, s2, and s3 after the operations below?

1s1 := []byte("Hello world!")
2
3s2 := s1[:6]
4s2 := append(s2, []byte("gophers")...)
5
6s3 := s1[6:11]
7s3 = append(s3, byte('?'))

References


  1. Zero value is the default value of an uninitialized type in Go. For example, the zero value of a bool is false, integer types are 0, and slices, functions, maps, etc. are nil↩︎

  2. The range keyword is used to iterate over the indexes and (optionally) the values of an array or slice. It returns the index and the value of each element. If the result is decomposed into one variable such as i := range slice, it will only return the index. If it is decomposed into two variables such as i, v := range slice, it will return both the index and the value. Similarly, if you only need the value, you can use _ to discard the index, such as _, v := range slice. The range iteration behaves differently on maps and channels. ↩︎

  3. linux.die.net/man/3/realloc ↩︎

  4. The growth factors are not constant, in practice. To see the actual calculation of the "smooth" growth factors, you can check the nextslicecap function in Go runtime source code↩︎

  5. For more info about the garbage collector, see tip.golang.org/doc/gc-guide ↩︎

🔔 Stay tuned 🔔
Enjoying the content? Subscribe to my newsletter and don't miss new articles. Don't worry, I won't spam you.