Agentic AI Engineering with Python: Live Course
GoArrays, Slices and Maps

How Slices Grow

s := []int{1, 2, 3}
t := s
t = append(t, 4)
t[0] = 99

fmt.Println(s[0])    // 1
s := make([]int, 3, 10)
copy(s, []int{1, 2, 3})
t := s
t = append(t, 4)
t[0] = 99

fmt.Println(s[0])    // 99

Same three statements, different answers. The only difference is the capacity, and understanding why is the difference between using slices confidently and being occasionally ambushed by them.

What append actually does

   append(s, x)

        ├─ is len(s) < cap(s)?

        ├─ YES →  write x at position len(s)
        │         return a header with len+1
        │         SAME backing array

        └─ NO  →  allocate a bigger array
                  copy all existing elements into it
                  write x
                  return a header pointing at the NEW array
                  the old array is untouched

That branch is the whole story. When there is spare capacity, append writes in place and every slice sharing that array sees the change. When there is not, append moves to fresh memory and the connection is silently severed.

You cannot tell which happened by looking at the call. You can only tell by knowing the capacity.

Watching it happen

s := make([]int, 0)

for i := 0; i < 10; i++ {
    s = append(s, i)
    fmt.Printf("len=%2d cap=%2d\n", len(s), cap(s))
}
len= 1 cap= 1
len= 2 cap= 2
len= 3 cap= 4
len= 4 cap= 4
len= 5 cap= 8
len= 6 cap= 8
len= 7 cap= 8
len= 8 cap= 8
len= 9 cap=16
len=10 cap=16

Capacity doubles each time it is exceeded. Ten appends caused four allocations and copied a total of fifteen elements, which for ten items is fine and for ten million is not.

The growth rule

Go's growth strategy has changed across versions, and the current behaviour is roughly:

  • Below 256 elements, capacity doubles
  • Above 256, it grows by about 25 percent each time, easing toward 1.25x for very large slices
  • The result is then rounded up to fit a size class in the memory allocator

The reason for the change is that pure doubling wastes a great deal of memory on large slices. A 100 MB slice growing to 200 MB when one element is added is not a good trade.

Do not write code that depends on these numbers. They are implementation details and have already changed twice. What is guaranteed is that append is amortised constant time, which means a sequence of n appends costs O(n) overall even though individual calls occasionally do more work.

Why preallocating matters

// Three reallocations for 1000 elements, plus copying
var s []int
for i := 0; i < 1000; i++ {
    s = append(s, i)
}

// Zero reallocations
s := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
    s = append(s, i)
}

Benchmarked, the second is typically three to five times faster for a thousand elements, and the gap widens with size because the copying grows too.

The habit to build: whenever you know or can estimate the final length, pass it as the capacity.

users := make([]User, 0, len(rows))
names := make([]string, 0, len(users))
results := make(map[string]int, len(keys))     // maps take a size hint too

You do not need an exact number. Being roughly right eliminates most of the reallocations.

A common slip is make([]int, n) when you meant make([]int, 0, n). The first gives you a slice of n zeros, and appending to it produces 2n elements with n zeros at the front. If your output has mysterious leading zeros, this is why.

The aliasing bug, in full

Here is the version of this that shows up in real code:

func process(data []int) []int {
    result := data[:0]              // reuse the caller's array, len 0
    for _, v := range data {
        if v > 0 {
            result = append(result, v)
        }
    }
    return result
}

original := []int{1, -2, 3, -4, 5}
filtered := process(original)

fmt.Println(filtered)     // [1 3 5]
fmt.Println(original)     // [1 3 5 -4 5]  ← overwritten

The in-place filter is a legitimate technique, but it destroys the input. That is fine when you own the slice and intended it. It is a bug when the caller expected their data to survive.

Two ways to be explicit about which you meant:

// Allocate fresh, the caller's slice is safe
func process(data []int) []int {
    result := make([]int, 0, len(data))
    for _, v := range data {
        if v > 0 {
            result = append(result, v)
        }
    }
    return result
}
// Modify in place, and say so in the name and the doc comment
// filterInPlace removes non-positive values from data, reusing its storage.
// data must not be used afterwards.
func filterInPlace(data []int) []int {
    kept := data[:0]
    // ...
}

The subslice append trap

all := []string{"a", "b", "c", "d", "e"}

first := all[:2]                    // len 2, cap 5
first = append(first, "NEW")

fmt.Println(all)                    // [a b NEW d e]
fmt.Println(first)                  // [a b NEW]

first had capacity left over from all, so append wrote into position 2 of the shared array and clobbered "c".

This is exactly what the three index slice expression prevents:

first := all[:2:2]                  // cap forced to 2
first = append(first, "NEW")        // must allocate

fmt.Println(all)                    // [a b c d e], intact

Adopt this whenever you slice something and pass the result somewhere else. The cost is one allocation in the case where it would have shared, and the benefit is that a whole class of bug becomes impossible.

Growing a slice of slices

The doubling applies to the outer slice, and each inner slice grows independently:

groups := make([][]string, 0, 10)

for _, item := range items {
    idx := item.Group
    for len(groups) <= idx {
        groups = append(groups, nil)      // nil slices are fine to append to later
    }
    groups[idx] = append(groups[idx], item.Name)
}

Appending to groups[idx] when it is nil works, because append allocates for a nil slice. That is the same zero value convenience that makes map[string][]T grouping so clean.

Shrinking, and the memory that does not come back

big := make([]int, 1_000_000)
small := big[:10]

small has length 10 and capacity 1,000,000. The entire eight megabyte array is still alive, because small points into it.

Reslicing never frees memory. To actually release it:

small := slices.Clone(big[:10])     // Go 1.21
big = nil                           // now the large array can be collected

Or with the older idiom:

small := make([]int, 10)
copy(small, big[:10])
big = nil

The same applies to s = s[:0]. That resets the length so you can reuse the storage, which is efficient, but it does not return any memory. Reuse is usually what you want:

buf := make([]byte, 0, 4096)
for {
    buf = buf[:0]              // reuse the same 4 KB, no new allocation
    buf = readInto(buf)
    process(buf)
}

For genuinely hot paths where buffers are created and discarded constantly, sync.Pool holds a set of reusable buffers and hands them out on demand. It is the standard tool for this and the concurrency section touches on it. Reach for it after a profile shows allocation pressure, not before.

A checklist for slice-heavy code

Four questions to ask when you write or review code that passes slices around:

  1. Do I know the final size? If so, preallocate the capacity.
  2. Am I keeping a small piece of something large? If so, clone it.
  3. Am I handing a subslice to code I do not control? If so, use the three index form.
  4. Does this function change the length? If so, it must return the slice.

Those four cover essentially every slice problem you will hit in practice, and they are all cheap to apply.

Next, let's move to maps, where the sharing story is different and considerably simpler.

How is this guide?

Last updated on