Visual Guide to Slices in Go
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.
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
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 range
2 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.
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 append
ing 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
orrealloc
3 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 Capacity | Growth Factor |
---|---|
256 | 2.0 |
512 | 1.63 |
1024 | 1.44 |
2048 | 1.35 |
4096 | 1.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).
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
.
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.
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
- Go compiler source code
- Effective Go
- 100 Go Mistakes and How to Avoid Them
- Go Slices: usage and internals
-
Zero value is the default value of an uninitialized type in Go. For example, the zero value of a
bool
isfalse
, integer types are0
, and slices, functions, maps, etc. arenil
. ↩︎ -
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 asi := range slice
, it will only return the index. If it is decomposed into two variables such asi, 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
. Therange
iteration behaves differently onmap
s andchannel
s. ↩︎ -
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. ↩︎ -
For more info about the garbage collector, see tip.golang.org/doc/gc-guide ↩︎