append and Slice Growth

The built-in append function is how Go programs grow slices, one element or many at a time. It looks like simple syntax sugar, but underneath it involves array reallocation, capacity growth heuristics, and a backing-array-sharing model that trips up even experienced Go programmers. Understanding exactly when append reuses existing memory versus when it allocates a brand new array is essential for writing correct, efficient Go code — and for avoiding one of the language’s most common classes of subtle bugs.

Overview: How Slices and append Work

A Go slice is not the data itself. It is a small header value with three fields: a pointer to an underlying array, a length (len, how many elements are currently visible), and a capacity (cap, how many elements the underlying array can hold starting from the slice’s pointer before it runs out of room). You never see this header directly, but every slice variable you write is really this three-word struct being copied around.

append(s, v) works like this: it checks whether len(s) + 1 <= cap(s). If there is spare capacity, it writes the new element into the next free slot in the existing underlying array and returns a slice header with the same pointer and the same capacity, but a length one larger. No allocation happens, and any other slice that shares that same underlying array will see the write too, if its own length reaches far enough to expose that slot. If there is not enough capacity, the Go runtime allocates a brand new, larger array, copies every existing element over, writes the new element(s) after them, and returns a slice header pointing at the new array. From that moment on, the old and new slices are completely independent — changes to one no longer affect the other.

This is why append always returns a value that you must assign back, typically to the same variable: s = append(s, v). The function cannot grow the slice you passed in place, because a Go function receives a copy of the slice header. If a reallocation happened, the caller’s original header would still point at the old, shorter array unless you capture the new header that append returns.

When a reallocation is required, Go does not simply grow the array by one element at a time — that would mean re-copying the whole slice on every single append, which is prohibitively slow. Instead the runtime picks a new capacity larger than what is strictly needed, following a growth strategy that is roughly: double the capacity for smaller slices, and grow by a smaller factor (around 25%) once a slice becomes very large, trading a bit of extra memory for far fewer copies over the slice’s lifetime. The precise numbers are an implementation detail that has shifted slightly between Go releases, and the memory allocator also rounds the requested size up to the nearest size class it supports, which is why the capacity you observe is sometimes a little larger than a naive doubling would predict. The lesson to take away is not the exact formula, but the shape: append amortizes the cost of growth so that appending N elements one at a time is, on average, proportional to N rather than N-squared.

Syntax

The general form of append is:

slice = append(slice, item1, item2, itemN)
slice = append(slice, anotherSlice...)
  • slice — the slice being appended to; its element type determines what can be appended.
  • item1, item2, … — one or more individual values of the slice’s element type to add at the end.
  • anotherSlice… — the ... spread operator expands a second slice’s elements as individual arguments, letting you concatenate two slices.
  • return valueappend always returns a slice header (possibly pointing at a new array); you must assign it somewhere, almost always back to the original variable.

Examples

Example 1: Watching Capacity Grow

package main

import "fmt"

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

Output:

[0] 1 1
[0 1] 2 2
[0 1 2] 3 4
[0 1 2 3] 4 4
[0 1 2 3 4] 5 8

Notice that capacity does not grow on every append. It jumps from 1 to 2, then to 4 (at which point there is spare room, so the fourth append reuses that same array with no growth at all), and finally to 8 when the fifth element needs more room than is left. This is the doubling strategy in action on a small slice.

Example 2: Aliasing Through a Shared Backing Array

package main

import "fmt"

func main() {
	original := []int{1, 2, 3, 4, 5}
	a := original[0:2]
	fmt.Println("before:", original, a, len(a), cap(a))

	a = append(a, 99)
	fmt.Println("after:", original, a)
}

Output:

before: [1 2 3 4 5] [1 2] 2 5
after: [1 2 99 4 5] [1 2 99]

a is a slice of original‘s first two elements, but because it was created by slicing rather than by make, it shares original‘s underlying array and inherits a capacity of 5 (from index 0 to the end of the array). When append(a, 99) runs, there is spare capacity, so it writes 99 directly into index 2 of the shared array — overwriting the 3 that original used to see there. Both slices change even though only a was appended to.

Example 3: Appending Multiple Values and Spreading Slices

package main

import "fmt"

func addNames(names []string, newNames ...string) []string {
	names = append(names, newNames...)
	return names
}

func main() {
	names := []string{"Alice", "Bob"}
	names = addNames(names, "Carol", "Dave")
	fmt.Println(names)

	more := []string{"Eve", "Frank"}
	names = append(names, more...)
	fmt.Println(names)
}

Output:

[Alice Bob Carol Dave]
[Alice Bob Carol Dave Eve Frank]

addNames itself takes a variadic parameter and simply forwards it into append using .... In main, the same ... operator is used again to spread an entire slice, more, into a second append call, which is the idiomatic way to concatenate two slices in Go.

How append Works Step by Step

When you write s = append(s, v), this sequence happens:

  1. Go evaluates len(s) + 1 and compares it against cap(s).
  2. If cap(s) is large enough, the runtime writes v into the array slot at index len(s), then returns a new slice header with the same pointer and capacity but len(s)+1 as its length. This is O(1) and touches no other memory.
  3. If cap(s) is too small, the runtime’s growslice logic computes a new, larger capacity (roughly doubling for smaller slices, growing more conservatively once the slice is large), rounds that up to a size the memory allocator can serve efficiently, and allocates a fresh array of that size.
  4. Every existing element is copied from the old array into the new one.
  5. The new value(s) are written after the copied elements.
  6. A slice header pointing at the new array, with the updated length and the new (larger) capacity, is returned.
  7. The old array becomes garbage once nothing else references it, and is eventually reclaimed by the garbage collector.

Because step 2 and step 3–6 are indistinguishable from the caller’s point of view — both simply return a slice header — you can never assume from the code alone whether a given append call allocated or not. That uncertainty is exactly why the aliasing bug in Example 2 is so easy to introduce by accident.

Common Mistakes

Mistake 1: Discarding append’s Return Value

It is tempting to think of append as mutating the slice in place, the way you might expect from a method call in another language. It does not:

func addItem(s []int, v int) {
	append(s, v) // return value discarded -- the caller's slice never changes
}

Since append may or may not reallocate, and either way it returns a new header, ignoring that return value means the caller’s variable keeps its old length forever — the appended element is silently lost. The fix is to always capture and use the return value:

package main

import "fmt"

func addItem(s []int, v int) []int {
	s = append(s, v)
	return s
}

func main() {
	nums := []int{1, 2, 3}
	nums = addItem(nums, 4)
	fmt.Println(nums)
}

Output:

[1 2 3 4]

Mistake 2: Assuming a Sub-Slice Is an Independent Copy

Slicing an existing slice does not copy its data, and passing that sub-slice into a function that appends to it can silently corrupt memory the caller still relies on:

package main

import "fmt"

func withExtra(s []int) []int {
	return append(s, 0)
}

func main() {
	buf := make([]int, 3, 5)
	buf[0], buf[1], buf[2] = 1, 2, 3

	a := buf[:2]
	b := withExtra(a)

	fmt.Println(buf, a, b)
}

Output:

[1 2 0] [1 2] [1 2 0]

a has length 2 but capacity 5, because it shares buf‘s backing array. Inside withExtra, append(s, 0) finds spare capacity and writes 0 straight into buf‘s third slot, silently turning the 3 that buf held into a 0. The caller never touched buf directly, yet it changed. The fix is to make an explicit, independent copy before appending whenever the function must not affect the caller’s data:

package main

import "fmt"

func withExtra(s []int) []int {
	result := make([]int, len(s), len(s)+1)
	copy(result, s)
	return append(result, 0)
}

func main() {
	buf := make([]int, 3, 5)
	buf[0], buf[1], buf[2] = 1, 2, 3

	a := buf[:2]
	b := withExtra(a)

	fmt.Println(buf, a, b)
}

Output:

[1 2 3] [1 2] [1 2 0]

buf is left untouched because result is a freshly allocated array that copy fills before append ever runs. A three-index slice expression, a := buf[:2:2], is another common fix: it caps a‘s capacity at its own length, so any append to a is forced to allocate a new array instead of reusing buf‘s.

Best Practices

  • Always reassign the result of append, usually to the same variable (s = append(s, v)); never call it and ignore the return value.
  • When you know roughly how many elements a slice will end up holding, preallocate with make([]T, 0, n) to avoid repeated reallocation and copying as it grows.
  • Never rely on whether a particular append call happens to allocate or not — that is an implementation detail that can change based on capacity alone, not something your program’s correctness should depend on.
  • If a function must not mutate data the caller still holds a reference to, make an explicit copy with copy() (or a three-index slice expression) before appending, rather than appending to a slice you were merely handed.
  • Use append(dst, src...) to concatenate or spread one slice into another; it is the idiomatic replacement for manual index-based copying loops.
  • Do not append to a shared slice from multiple goroutines without synchronization — concurrent appends to slices with overlapping backing arrays are a data race even when each goroutine only appends to its own header.

Practice Exercises

  1. Write a function mergeInts(a, b []int) []int that returns a new slice containing all of a‘s elements followed by all of b‘s elements, without ever mutating the backing arrays of a or b. Verify with a case where a has spare capacity that this still does not affect a.
  2. Write a loop that appends the numbers 1 through 10 to a slice created with make([]int, 0), printing len and cap after each append. Predict the capacity sequence on paper first, then compare it to the real output.
  3. Reproduce the aliasing bug from Common Mistakes on purpose: create a slice with spare capacity, take a sub-slice, append to the sub-slice, and print the original to observe the corruption. Then fix it using the three-index slice expression a[:len(a):len(a)] instead of a full copy, and confirm the original is no longer affected.

Summary

  • A slice is a header (pointer, length, capacity) over an underlying array, not the data itself.
  • append writes in place, reusing the existing array, when there is spare capacity; otherwise it allocates a new, larger array and copies everything over.
  • Because you cannot tell from the call site which case occurred, always reassign append‘s return value back to a variable.
  • Slices created by slicing another slice share its backing array and its remaining capacity, which means an in-place append to one can silently overwrite data another slice still references.
  • Go’s growth strategy roughly doubles capacity for small slices and grows more conservatively for large ones, giving append amortized O(1) cost per element over a sequence of appends.
  • Use make with a capacity hint, explicit copy(), or a three-index slice expression when you need control over allocation or independence from a shared array.