Go

Go Slices: What append, copy, and cap Actually Do to the Underlying Array

Understand Go slices, including when append reallocates, how copy handles overlap, why re-slices alias, and how to avoid retaining pointers.

By Niko Minadze6 min read

Editorial illustration for Go Slices: What append, copy, and cap Actually Do to the Underlying Array

The practical rule for Go slices is simple: a slice describes part of an array. It does not contain the array itself. If len(s) < cap(s), an append can use the existing array. If the new length would exceed cap(s), append must grow the slice and may return a descriptor for a new array.

That model explains most surprising behavior around golang slices: re-slicing aliases memory, append must be assigned back, and deleting an element does not necessarily remove its pointer from the underlying array.

A slice is a descriptor, not an array

Conceptually, a slice contains an array pointer, a length, and a capacity. This explanatory type is not an API to use in application code, but it is a useful model:

type sliceHeader struct {
    Length        int
    Capacity      int
    ZerothElement *byte
}

len(s) is the number of elements currently exposed through the slice. cap(s) is how many elements are available from the slice's first element to the end of the underlying array.

For example, if a slice begins at index 2 of an eight-element array, its capacity is 6. Its length may be smaller:

array := [8]int{}
s := array[2:5]

fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 6

Capacity is the growth ceiling for that slice descriptor. You can re-slice up to it, but attempting to re-slice beyond it causes a runtime panic. append can cross that ceiling only by returning a grown slice, potentially backed by another array.

The official Go slices introduction describes a slice as a descriptor of an array segment rather than an array in its own right.

Slicing does not copy

A slice expression creates another descriptor pointing into the same underlying array. Consequently, writes through a sub-slice are visible through other slices covering the same element:

package main

import "fmt"

func main() {
    original := []int{10, 20, 30, 40}
    middle := original[1:3]

    middle[0] = 99

    fmt.Println(middle)   // [99 30]
    fmt.Println(original) // [10 99 30 40]
}

Only middle's descriptor is new. Its elements were not duplicated. This aliasing is efficient, but it means a helper that accepts and modifies a slice may also modify data visible to its caller.

Predicting whether append reallocates

When enough capacity remains, append writes new elements into the existing underlying array and returns an updated slice. When capacity is insufficient, Go allocates a larger array, copies the existing data, and returns a slice describing the grown storage.

package main

import "fmt"

func main() {
    base := make([]int, 2, 3)
    base[0], base[1] = 10, 20

    fmt.Println("before:", len(base), cap(base))

    within := append(base, 30)
    fmt.Println("within capacity:", len(within), cap(within))
    fmt.Println("same array:", &base[0] == &within[0])

    grown := append(within, 40)
    fmt.Println("after exceeding capacity:", len(grown), cap(grown))
    fmt.Println("same array:", &within[0] == &grown[0])
}

The first append fits because the original length is 2 and capacity is 3. The second requires a length of 4, exceeding that capacity, so grown refers to different storage. The exact larger capacity is not something this code needs to assume.

Always retain the returned value:

s = append(s, value)

Assigning the result matters even when a particular append happens to reuse storage: the returned slice still has the updated length. If growth requires another array, the returned value also carries the new array pointer and capacity.

Pre-allocate capacity, or limit it deliberately

If the expected size is known, make can allocate length and capacity together:

items := make([]string, 0, 100)
items = append(items, "first")

fmt.Println(len(items)) // 1
fmt.Println(cap(items)) // 100

This gives the first 100 elements room in the allocated array, avoiding capacity growth while the slice remains within that limit.

Sometimes the opposite is useful. A three-index slice expression limits the capacity exposed to a derived slice:

values := []int{10, 20, 30, 40, 50, 60}
window := values[2:4:4]

fmt.Println(window)      // [30 40]
fmt.Println(len(window)) // 2
fmt.Println(cap(window)) // 2

window = append(window, 99) // must grow beyond the limited capacity

The form is s[low:high:max]. Here, setting max equal to high makes the resulting capacity equal to its length. That prevents an append to window from reusing the remaining portion of values.

copy moves elements; it does not create a slice

The built-in has this signature:

func copy(dst, src []T) int

It copies up to the smaller of len(dst) and len(src) and returns the number of elements copied. The destination storage must already exist:

src := []int{1, 2, 3, 4}
dst := make([]int, 2)

n := copy(dst, src)
fmt.Println(n)   // 2
fmt.Println(dst) // [1 2]

copy also handles overlapping source and destination slices from the same array correctly:

numbers := []int{1, 2, 3, 4, 5}
n := copy(numbers[1:], numbers[:4])

fmt.Println(n)       // 4
fmt.Println(numbers) // [1 1 2 3 4]

This makes copy particularly useful for shifting elements during deletion or insertion. The overlap behavior is documented in the official Go slices introduction.

Delete pointer elements without retaining them

Shortening a slice does not clear the unused portion of its underlying array. For slices containing pointers—or structs containing pointer fields—removed values can therefore remain reachable while that array lives.

A safe delete shifts the tail, clears the vacated slot, and then shortens the slice:

type Item struct {
    Name string
}

func Delete(a []*Item, i int) []*Item {
    copy(a[i:], a[i+1:])
    a[len(a)-1] = nil
    return a[:len(a)-1]
}

Cutting a range requires clearing every vacated trailing slot:

func Cut(a []*Item, i, j int) []*Item {
    copy(a[i:], a[j:])

    newLen := len(a) - j + i
    for k := newLen; k < len(a); k++ {
        a[k] = nil
    }

    return a[:newLen]
}

These helpers assume valid indexes. For a non-pointer element type, assign that type's zero value instead of nil. The Go SliceTricks page documents why clearing those slots matters for garbage collection.

make plus copy versus append-based copying

For a straightforward independent slice with the same length, make plus copy is explicit:

b := make([]int, len(a))
copy(b, a)

Append-based forms can express the same intent:

b := append([]int(nil), a...)

If more elements will immediately be appended, an append-oriented copy with planned capacity can be convenient:

b := make([]int, 0, len(a)+extra)
b = append(b, a...)
// Append the extra elements to b next.

The SliceTricks comparison notes that append variants may be advantageous when additional elements follow, while its specific performance ranking was tied to the Go 1.16 toolchain. Treat clarity and required future capacity as the first decision; benchmark the current workload if the difference is important.

Comments

No comments yet.

Leave a comment

Your comment appears after approval. Plain text only; links stay as text. Line breaks and indentation are kept.

Up to 5,000 characters. No account or email needed.

All articles

Type at least two characters.

Press <kbd>Esc</kbd> to closeOpen the search page