Learn Go Series (#5) - Arrays, Slices, and Slice Internals

Words
2901
Reading
13 min
Listen
Play
1d

Learn Go Series (#5) - Arrays, Slices, and Slice Internals

go-banner.png

What will I learn

  • The difference between an array (fixed size, a value) and a slice (a growable view), and why you almost always use slices;
  • What a slice really is under the hood: a pointer, a length, and a capacity;
  • How append grows a slice, when it reallocates, and the aliasing gotcha that surprises everyone once;
  • copy, and how to take an independent copy so you do not mutate a shared backing array;
  • The nil slice, why it is safe to append to, and how to build two-dimensional slices;
  • Idioms that keep slice code correct rather than mysteriously wrong.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Go distribution (1.27 or newer, from go.dev/dl) -- tested against Go 1.27;
  • Episodes 1-4, or comfort with loops, functions and for range;
  • The ambition to learn Go programming.

Difficulty

  • Beginner

Curriculum (of the Learn Go Series):

Learn Go Series (#5) - Arrays, Slices, and Slice Internals

The slice is the collection you will use more than any other in Go, and it is also the one with the most interesting internals -- interesting enough that not understanding them leads to a specific, memorable bug where changing one slice silently changes another. Today we build the real mental model: a slice is a small three-field view onto an array, and once you hold that picture, append, sub-slicing, and the aliasing surprise all make sense. I keep promising this episode -- back in episode 4 I flagged twice that "range copies, slices share" is a thread we would pick up here, so let us finally pick it up properly.

I want to be honest about why I am spending a whole episode on what looks, from the outside, like "the list type". In most languages the growable array is a black box you never have to open. In Go the box is deliberately transparent: the language hands you the pointer, the length and the capacity, and it expects you to know what those are doing. That is not extra homework for its own sake -- it is the difference between writing slice code that is correct because you understand it, and slice code that is correct by accident until the day it is not. Get the model right once and you never think about it again. First, last episode's exercises.

Solutions to Episode 4 Exercises

Exercise 1 -- FizzBuzz with a tagless switch. Test the most specific case (divisible by both) first:

package main

import "fmt"

func main() {
    for i := 1; i <= 30; i++ {
        switch {
        case i%15 == 0:
            fmt.Println("FizzBuzz")
        case i%3 == 0:
            fmt.Println("Fizz")
        case i%5 == 0:
            fmt.Println("Buzz")
        default:
            fmt.Println(i)
        }
    }
}

Order matters: i%15 == 0 has to come before the %3 and %5 cases, or a multiple of fifteen would match Fizz first and stop.

Exercise 2 -- parse-or-skip. Parse in the if init, continue on failure:

package main

import (
    "fmt"
    "strconv"
)

func main() {
    inputs := []string{"12", "x", "7", "-4"}
    total := 0
    for _, s := range inputs {
        n, err := strconv.Atoi(s)
        if err != nil {
            continue
        }
        total += n
    }
    fmt.Println("total:", total) // 12 + 7 - 4 = 15
}

Exercise 3 -- first match in a grid, with a labeled break. Named returns pre-set to the not-found answer:

package main

import "fmt"

func find(grid [][]int, target int) (row, col int, found bool) {
    row, col, found = -1, -1, false
search:
    for r, line := range grid {
        for c, v := range line {
            if v == target {
                row, col, found = r, c, true
                break search
            }
        }
    }
    return
}

func main() {
    grid := [][]int{{1, 2}, {3, 4}}
    fmt.Println(find(grid, 4)) // 1 1 true
    fmt.Println(find(grid, 9)) // -1 -1 false
}

break search leaves both loops at once. Now, slices.

Arrays: fixed size, and a value

An array has its length baked into its type: [3]int and [4]int are different, incompatible types. Arrays are values -- assigning one, or passing it to a function, copies the whole thing. That is rarely what you want for a big collection, which is a large part of why slices exist:

package main

import "fmt"

func main() {
    var a [3]int          // an array of 3 ints, all zero
    a[0], a[1], a[2] = 10, 20, 30

    b := a                // COPY: b is an independent array
    b[0] = 999
    fmt.Println("a:", a)  // [10 20 30] -- unchanged
    fmt.Println("b:", b)  // [999 20 30]

    primes := [...]int{2, 3, 5, 7} // [...] lets the compiler count
    fmt.Println("len:", len(primes))
}

b := a copies all three elements, so mutating b leaves a alone. The [...]int{...} form asks the compiler to count the elements for you. Arrays are useful when the size is genuinely fixed and small (an RGBA colour, a 32-byte hash, a lookup table with one slot per weekday), but for anything growable you reach for a slice.

Two more array facts worth having early, because they surprise people who come from Python's list. First, the length is part of the type, which means it is fixed at compile time and you cannot grow an array -- [3]int is forever three ints. Second, arrays are comparable with == when their element type is: two arrays are equal if every element is equal, element by element. That, combined with being value types, means arrays can be used as map keys (something a slice can never do, as we will see next episode):

package main

import "fmt"

func main() {
    a := [3]int{1, 2, 3}
    b := [3]int{1, 2, 3}
    fmt.Println(a == b) // true -- element-by-element comparison

    // an array can be a map key precisely because it is comparable
    seen := map[[2]int]bool{}
    seen[[2]int{4, 5}] = true
    fmt.Println(seen[[2]int{4, 5}]) // true
}

So arrays are not useless -- they are the right tool when the size is a fixed, meaningful part of the design. But 95% of the time you want the thing that grows, and that thing is the slice.

A slice is a view: pointer, length, capacity

A slice is not a collection; it is a small three-field descriptor -- a pointer to an underlying array, a length (how many elements you can see), and a capacity (how many elements exist from the pointer to the end of that backing array). len and cap report those two numbers. make creates a slice with a chosen length and capacity:

package main

import "fmt"

func main() {
    s := make([]int, 3, 8) // length 3, capacity 8
    fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)

    nums := []int{2, 4, 6, 8, 10}
    mid := nums[1:4] // elements at index 1,2,3 -> [4 6 8]
    fmt.Printf("mid=%v len=%d cap=%d\n", mid, len(mid), cap(mid))
    // cap(mid) is 4: from index 1 to the end of the backing array (index 4)
}

make([]int, 3, 8) gives you three visible zeroes with room to grow to eight before any reallocation. Sub-slicing nums[1:4] does not copy -- mid points into the same backing array as nums, which is the setup for the gotcha two sections down.

The three-field picture is worth holding literally in your head, because everything else follows from it. Think of the slice value itself as a tiny struct of three machine words -- roughly struct { ptr *T; len int; cap int }. When you write s2 := s1, or pass a slice to a function, Go copies those three words. It does not copy the backing array. So the copy is cheap (three words, regardless of whether the slice holds ten elements or ten million), and both copies now point at the same underlying data. That single fact -- header copied, backing array shared -- explains the aliasing gotcha, why append must return a value, and why passing a slice to a function lets that function mutate your elements. Hold that picture and the rest of this episode is just consequences of it.

The two indices in a sub-slice, s[low:high], give you elements from low up to but not including high, so the new length is high - low. The capacity, though, runs from low all the way to the end of the backing array -- not to high. That is exactly why cap(mid) above is 4 and not 3: there is one more real element (nums[4]) sitting past the end of mid's view, and mid can grow into it. Remembering that "capacity reaches to the end of the array, not to the end of your view" is half of understanding the gotcha before you even hit it.

append, and when it reallocates

append adds elements to a slice. If there is spare capacity it writes into the existing backing array and returns a slice with a longer length. If there is not, it allocates a bigger array, copies the elements over, and returns a slice pointing at the new one. You must always assign the result back, because the returned slice may be a different one:

package main

import "fmt"

func main() {
    s := make([]int, 0, 2) // len 0, cap 2
    for i := 1; i <= 5; i++ {
        s = append(s, i)
        fmt.Printf("after append %d: len=%d cap=%d %v\n", i, len(s), cap(s), s)
    }
}

Watch the capacity jump as it fills: appending past cap triggers a reallocation to a larger array (Go roughly doubles it for small slices, and grows by a smaller factor once slices get large), so the capacity grows in steps, not one at a time. This amortised growth is the reason appending in a loop is efficient: yes, some appends pay for a copy of everything so far, but because the capacity doubles, those expensive copies get rarer and rarer as the slice grows, and the average cost per append works out to a constant. That is the same amortised-doubling trick behind Python's list.append and C++'s vector::push_back -- Go just does not hide it from you.

The exact growth factor is an implementation detail and you should not write code that depends on the specific numbers (they have changed between Go releases). What you should take away is the shape: capacity climbs in jumps, and each jump means a fresh backing array and a copy. If you know the final size up front, make([]int, 0, n) pre-allocates the whole thing once and every subsequent append writes in place -- no reallocations, no copies. In a hot loop that builds a big slice, that one make with a capacity hint is often the single easiest performance win you will find, and it costs you nothing in readability:

package main

import "fmt"

func main() {
    // pre-sized: one allocation, no growth copies at all
    out := make([]int, 0, 1000)
    for i := 0; i < 1000; i++ {
        out = append(out, i*i)
    }
    fmt.Println(len(out), cap(out)) // 1000 1000 -- cap never overshot
}

Because we asked for a capacity of 1000 and appended exactly 1000 times, the capacity ends at exactly 1000 -- it never had to grow, so it never overshot. Compare that with the previous example where the capacity leapfrogged past the length in doubling steps. Same result, but one allocation instead of a handful.

The aliasing gotcha

Here is the bug everyone hits once. Because a sub-slice shares its backing array with the original, and because append writes into spare capacity in place, an append to a sub-slice can silently overwrite elements of the original slice:

package main

import "fmt"

func main() {
    base := []int{1, 2, 3, 4, 5}
    first3 := base[:3] // len 3, but cap 5 (shares base's backing array)

    first3 = append(first3, 100) // there IS spare capacity, so this writes base[3]!
    fmt.Println("first3:", first3) // [1 2 3 100]
    fmt.Println("base:  ", base)   // [1 2 3 100 5] -- base[3] was clobbered
}

first3 has length 3 but capacity 5, so appending 100 lands in the shared backing array at index 3 -- which is base[3]. The fix, when you need a sub-slice that cannot disturb the original, is a full-slice expression base[:3:3] that caps the capacity at 3, forcing the next append to reallocate. Or simply copy, which is the next section.

copy: an independent duplicate

When you want a slice that genuinely does not share memory with another, use the built-in copy. It copies min(len(dst), len(src)) elements and returns how many it moved:

package main

import "fmt"

func main() {
    src := []int{1, 2, 3}
    dst := make([]int, len(src))
    n := copy(dst, src) // copy src into freshly allocated dst

    dst[0] = 99
    fmt.Printf("copied %d; src=%v dst=%v\n", n, src, dst) // src unchanged
}

Because dst is its own array, mutating dst[0] leaves src alone. copy is the honest way to hand out a slice a caller can mutate freely, or to snapshot data before changing it. A handy detail: copy handles overlapping regions correctly, so you can even copy a slice onto a shifted view of itself (which is how the deletion idiom below works), and it also knows how to copy the bytes of a string into a []byte -- copy(buf, "hello") just works.

Slices as function arguments

Now we can answer a question that trips up everyone at some point: if I pass a slice to a function and the function changes an element, does my slice see the change? The answer follows directly from the three-field picture. The function receives a copy of the header -- its own ptr, len, cap -- but that copied pointer still aims at your backing array. So writing to an existing element through the function's parameter writes to your data:

package main

import "fmt"

func double(xs []int) {
    for i := range xs {
        xs[i] *= 2 // writes into the shared backing array -- caller sees it
    }
}

func appendOne(xs []int) {
    xs = append(xs, 99) // reassigns the LOCAL header; caller never sees it
}

func main() {
    nums := []int{1, 2, 3}
    double(nums)
    fmt.Println(nums) // [2 4 6] -- element writes are visible

    appendOne(nums)
    fmt.Println(nums) // [2 4 6] -- STILL length 3, the append was lost
}

Look carefully at the difference. double mutates existing elements in place, so the caller sees [2 4 6]. But appendOne calls append and assigns the result to its own local copy of the header -- the caller's nums header is untouched, so from main's point of view nothing happened. This is why the standard-library convention, and the convention you should follow, is that any function which might grow a slice returns the slice, exactly like append itself does. If you find yourself wishing a function could grow the caller's slice without a return value, that is Go telling you to either return the new slice or pass a *[]int -- and nine times out of ten, returning it is the cleaner design.

Deleting and inserting elements

Go has no built-in "remove from slice" or "insert into slice", because slices are a thin layer over an array and there is nothing to hide. Both operations are one-liners built from sub-slicing and append, and both are worth committing to memory. To delete the element at index i, append the tail (everything after i) onto the head (everything before i):

package main

import "fmt"

func main() {
    s := []int{10, 20, 30, 40, 50}
    i := 2                              // delete the 30
    s = append(s[:i], s[i+1:]...)       // shift the tail left over index i
    fmt.Println(s)                      // [10 20 40 50]

    // insert 25 at index 1: grow by one, shift the tail right, drop it in
    s = append(s, 0)                    // make room
    copy(s[2:], s[1:])                  // shift right from index 1
    s[1] = 25
    fmt.Println(s)                      // [10 25 20 40 50]
}

The ... in append(s[:i], s[i+1:]...) is the spread we met in episode 3 -- it feeds each element of the tail slice into append as a separate argument. Note that this deletion mutates the original backing array (it shifts elements left in place), which is efficient but means any other slice that shared that array now sees the shifted data -- the same aliasing story, so if that matters, copy first. If you do not care about preserving order, there is an even cheaper delete: overwrite index i with the last element and shrink by one (s[i] = s[len(s)-1]; s = s[:len(s)-1]), which is O(1) instead of O(n) because it moves a single element instead of shifting the whole tail.

The nil slice, and building a grid

A slice's zero value is nil -- length 0, capacity 0, no backing array. Crucially, it is safe to append to a nil slice: the first append allocates for you. So you rarely need to special-case "empty". And a two-dimensional slice is just a slice of slices, each of which you allocate:

package main

import "fmt"

func main() {
    var s []int // nil slice
    fmt.Println(s == nil, len(s)) // true 0
    s = append(s, 1, 2, 3)        // appending to nil just works
    fmt.Println(s, s == nil)      // [1 2 3] false

    rows, cols := 2, 3
    grid := make([][]int, rows)
    for r := range grid {
        grid[r] = make([]int, cols) // each row its own slice
    }
    grid[1][2] = 7
    fmt.Println(grid) // [[0 0 0] [0 0 7]]
}

Starting from a nil slice and appending is a completely normal, idiomatic way to build up a result -- you do not need make unless you want to pre-size. For a grid, you allocate the outer slice of rows and then each inner row, because Go has no built-in rectangular 2D slice type.

One nuance that catches people: a nil slice and an empty slice ([]int{} or make([]int, 0)) behave almost identically -- len is 0 for both, you can range over both (zero iterations), and you can append to both. The difference is that nil == nil is true while an empty non-nil slice is not equal to nil. In almost all code you should treat them the same and prefer len(s) == 0 over s == nil when you want to ask "is this empty?", because that question is what you actually mean and it is correct for both cases. The one place the distinction leaks out is serialization -- for example, a nil slice marshals to JSON null while an empty slice marshals to [] -- but that is a story for a later episode. For now: reach for len(s) == 0, and do not lose sleep over nil-versus-empty.

The same idea in Python

Python's list is a growable array much like a Go slice, and slicing a Python list makes a copy, which sidesteps Go's aliasing gotcha entirely -- but also means Python is copying where Go is sharing (and therefore cheaper):

base = [1, 2, 3, 4, 5]
first3 = base[:3]     # a COPY in Python
first3.append(100)
print(first3)         # [1, 2, 3, 100]
print(base)           # [1, 2, 3, 4, 5] -- untouched

grid = [[0] * 3 for _ in range(2)]
grid[1][2] = 7

That difference is the whole lesson: Python slices copy, Go slices share. Go's approach is faster and gives you control over allocations, at the cost of the one gotcha you now know to watch for. (Note the Python grid uses a comprehension precisely because [[0]*3]*2 would share the inner list -- Python has its own aliasing trap, so nobody gets to feel too smug here.)

It is worth naming the trade explicitly, because it is a theme that will come back all through this series. Python optimises for "the obvious thing is safe": slicing copies, so you almost never surprise yourself, and you pay for that safety in allocations you cannot see and cannot easily avoid. Go optimises for "the obvious thing is cheap and explicit": slicing shares, so it is fast and allocation-free, and you pay for that with one sharp edge you have to learn once. Neither choice is wrong -- they are aimed at different priorities. Coming from Python, the adjustment is simply to remember that in Go a sub-slice is a window onto the same glass, not a photograph of it. Once that clicks, quit some of the "wait, why did that change?" moments just stop happening, because you already knew the two slices were looking at the same array.

Exercises

  1. Prove the reallocation. Start with make([]int, 0, 1) and append eight values in a loop, printing len and cap each time. From the capacity jumps, work out Go's growth pattern for small slices, and note it in a comment.

  2. A safe sub-slice. Take base := []int{1,2,3,4,5}, make a length-3 sub-slice two ways: once with base[:3] and once with the full-slice expression base[:3:3]. Append to each and show that only the first one clobbers base.

  3. Remove an element. Write removeAt(s []int, i int) []int that returns a new slice with the element at index i removed, using append(s[:i], s[i+1:]...). Then explain in a comment why this mutates the original backing array, and how you would copy first if that mattered.

What we learned

  • An array ([N]T) has a fixed size baked into its type and is a value -- assigning or passing it copies every element;
  • A slice is a three-field view -- pointer, length, capacity -- onto a backing array; len and cap report the last two, and make([]T, len, cap) builds one;
  • append writes into spare capacity in place, or reallocates (roughly doubling) when full, so you must always assign its result back;
  • Sub-slicing shares the backing array, so append to a sub-slice can silently overwrite the original -- guard with a full-slice expression s[:n:n] or a copy;
  • copy makes an independent duplicate (and handles overlap and string-to-[]byte); the nil slice is a safe, append-able empty, and you should ask "is it empty?" with len(s) == 0 rather than s == nil;
  • passing a slice to a function copies the header but shares the backing array, so element writes are visible to the caller but an append inside the function is not -- which is why growing functions return the slice;
  • deleting is append(s[:i], s[i+1:]...) (or the O(1) swap-with-last if order does not matter) and inserting is a grow-then-copy-shift-then-assign -- both are just sub-slicing and append, no magic;
  • a 2D slice is a slice of separately allocated slices, and an array (being comparable and a value) can be a map key where a slice cannot;
  • Where Python copies on slice, Go shares -- faster and more controllable, at the cost of the one aliasing gotcha you now recognise.

Next episode: the other everyday collection, the map -- how to use it, how to build a set from it, why iteration order is deliberately random, and the comma-ok idiom for "is this key present". See you there.

Veel plezier met slices, en tot de volgende! ;-)

scipio@scipio