make, length, and capacity

The previous lesson introduced len, and it also showed that slices can share their underlying data. This lesson adds the other half of the picture: capacity. len tells you how many elements the slice currently exposes. cap tells you how much room it has before it needs to grow. Once you have those two ideas, make starts to feel much less mysterious.

make creates a slice with room in it

There are three common ways to get a slice:

  • In the last lesson, you saw the literal form e.g. []int{10, 20, 30}. This creates a slice with three elements, each initialised to the value you write.
  • You can also declare a slice variable without an initial value, e.g. var names []string. That gives you a slice with no elements i.e. value nil and length zero.
  • The third way is make. It can give you a slice with a chosen length, or an empty slice with extra room set aside for later growth. Any elements that are present start at the zero value of the element type.

Here is the difference in code:

nums := []int{10, 20, 30}       // slice with 3 elements, len 3
var names []string              // nil slice, len 0
buffer := make([]int, 0, 10)    // empty slice with room for 10 values

Use a literal when you already know the elements. Use var when you want the zero value of the slice type. Use make when you want a slice with a chosen starting length, or extra room to grow without reallocating right away.

One subtle point: var names []string and make([]string, 0, 5) are both empty slices, but they are not the same kind of empty slice:

var a []string
b := make([]string, 0, 5)

fmt.Println(a == nil)        // true
fmt.Println(b == nil)        // false
fmt.Println(len(a), cap(a))  // 0 0
fmt.Println(len(b), cap(b))  // 0 5

If you only print them, both appear as []. The difference is that b already has room reserved for later growth, while a does not.

make takes the slice type, a length, and an optional capacity:

a := make([]int, 3)       // length 3, capacity 3
b := make([]int, 3, 10)   // length 3, capacity 10

fmt.Println(a)                 // [0 0 0]
fmt.Println(len(a), cap(a))    // 3 3

fmt.Println(b)                 // [0 0 0]
fmt.Println(len(b), cap(b))    // 3 10

Both slices have three "real" elements, each initialised to the zero value of int. The difference is the underlying array: a can hold three elements total; b can hold ten. b has room for seven more additions before Go would have to allocate a larger array.

If you leave the capacity out, it defaults to the length. make([]int, 5) gives you a slice with len == cap == 5.

len versus cap

len(s) is the number of elements you can currently read or write through s. cap(s) is how many elements the backing array holds in total, counting from where the slice starts.

Underlying array:   [ 0  0  0  _  _  _  _  _  _  _ ]
                    |--------|                     |
                     len = 3                   cap = 10

The slice knows three things: where it starts, how far its "valid" range extends (length), and how far the underlying array actually reaches (capacity). Indexing is only allowed up to len - 1. Reading b[5] on the slice above is still a runtime panic, even though the underlying array has space at index 5, because the slice itself only exposes the first three positions:

b := make([]int, 3, 10)
fmt.Println(b[5])
// runtime error: index out of range [5] with length 3

The runtime checks len, not cap. Capacity controls how far append can grow without reallocating; it does not license reads. Out-of-length reads always panic, regardless of how much backing storage is standing behind the slice.

Why capacity matters

Capacity exists to avoid reallocations. Growing a slice past its capacity (with append, next lesson) forces Go to allocate a bigger underlying array, copy every existing element across, and return a slice that refers to the bigger array. You can see this happening by watching cap step up as you append into an empty slice:

s := []int{}
for i := 0; i < 10; i++ {
    s = append(s, i)
    fmt.Println(len(s), cap(s))
}

One run prints:

1 1
2 2
3 4
4 4
5 8
6 8
7 8
8 8
9 16
10 16

The exact jump pattern is not the point here. What matters is that capacity grows in chunks, not one element at a time. Once the length catches up with the capacity, the next append has to allocate a bigger backing array and copy the existing elements into it.

Pre-allocating skips every one of those reallocations:

s := make([]int, 0, 10)   // cap 10 from the start
for i := 0; i < 10; i++ {
    s = append(s, i)
    fmt.Println(len(s), cap(s))
}
// 1 10
// 2 10
// ...
// 10 10

One allocation, done. The difference is usually invisible for small slices; it starts to matter once you are collecting tens of thousands of elements or more, or doing so in a hot loop. Reach for make([]T, 0, n) when you know the target size and want to be kind to the allocator.

Task

In main:

  • Create a := make([]int, 3). Print len(a) and cap(a) on the same line with a space between them.
  • Create b := make([]int, 3, 10). Print len(b) and cap(b) on the same line.
  • Print b itself on its own line, to confirm its visible contents are three zeros even though the capacity is ten.

Expected output:

3 3
3 10
[0 0 0]
Expected output
3 3
3 10
[0 0 0]