The append Built-in

How the append function works, how it grows slices, and how to use it correctly in Go.

Go programs work with slices constantly, and slices rarely stay the same size. You build up collections of results, accumulate data from a loop, or combine existing slices into a new one. The append built-in is the tool for all of that. It adds elements to the end of a slice, handling all the memory management behind the scenes so you do not have to write manual array resizing.

The Signature and Basic Use

The signature of append is:

func append(slice []Type, elems ...Type) []Type

It takes a slice and zero or more values of the same element type, and it returns a slice that contains everything from the original slice followed by the new elements. The returned slice has the same element type as the input.

A minimal example:

package main
import "fmt"
func main() {
    var numbers []int
    numbers = append(numbers, 10)
    numbers = append(numbers, 20, 30)
    fmt.Println(numbers) // [10 20 30]
}

The first call to append starts with a nil slice. That is fine — append works on nil slices the same way it works on any other slice. The second call shows adding multiple elements in one go.

Nil slices and append are safe:

A nil slice has no backing array, but append treats it as a slice with length and capacity zero. After the first append, the returned slice points to a freshly allocated array and you have a usable slice. You never need to manually initialize a slice before appending.

Capturing the Return Value Is Mandatory

The most frequent mistake with append is ignoring the returned slice:

s := []int{1, 2, 3}
append(s, 4)
fmt.Println(s) // still [1 2 3]

append does not modify the slice header you pass in. It builds a new slice header and returns it. If the backing array had enough capacity, the new header points to the same array but with an increased length. If capacity was insufficient, the new header points to a completely different array. In both cases the original header s still has its original length and, if the array was not reallocated, still sees only the original elements — the newly appended element sits at an index beyond len(s) and is invisible to s.

Forgetting the assignment discards the append:

If you write append(s, elem) without capturing the result, nothing happens to the slice s. The compiler will even flag this as a mistake in many cases because the return value of append is unused. Always assign back: s = append(s, elem).

How Append Handles Capacity

A slice header stores a pointer to an underlying array, a length, and a capacity. When you call append, Go checks whether the underlying array has room for the new elements — specifically, whether len(s) + number_of_new_elements <= cap(s).

If there is room, append reuses the same backing array. It copies the new values into the array just beyond the current length, then returns a new slice header with the same pointer, the new length, and the same capacity. The original slice’s length does not change, but the underlying array now holds data beyond that length. This is the root of many shared‑array surprises (see the pitfalls section).

If there is not enough room, append allocates a new backing array that is large enough for the existing elements plus the new ones. It copies the original elements over, appends the new values, and returns a slice header pointing to this fresh array. The old backing array is left untouched.

s := make([]int, 3, 3) // len=3, cap=3
s[0], s[1], s[2] = 1, 2, 3
t := append(s, 4)
fmt.Println("s:", s, "len:", len(s), "cap:", cap(s)) // [1 2 3] 3 3
fmt.Println("t:", t, "len:", len(t), "cap:", cap(t)) // [1 2 3 4] 4 6 (cap ≥ 4)

Here the capacity of s was exactly 3, so appending forced a new allocation. The new slice t has its own backing array, and modifying t will not affect s.

The mental model: think of append as a function that always returns a new slice header. Whether that header points to the old array or a brand‑new one is an optimization detail that you control through capacity.

Appending One Slice to Another

To append all elements of slice src to slice dst, you use the ... syntax after src:

dst := []int{1, 2, 3}
src := []int{4, 5, 6}
dst = append(dst, src...)
fmt.Println(dst) // [1 2 3 4 5 6]

The ... unpacks the slice so that each element is passed as an individual argument to the variadic elems ...Type parameter. This is not a special language feature just for append — it works with any variadic function.

Appending a slice to itself is safe:

Because append may reallocate, you can safely write s = append(s, s...) to double a slice. Go will either reuse the old backing array if there is room (which only happens when len(s) <= cap(s) - len(s)) or allocate a new array, copy the original elements, and then append the copy of the original. The result is always consistent.

Appending a String to a Byte Slice

There is one special case in the language spec: you can append a string to a []byte slice using ...:

prefix := []byte("Hello, ")
full := append(prefix, "World!"...)
fmt.Println(string(full)) // Hello, World!

The string is treated as a sequence of bytes and appended element‑by‑element. This is a common pattern when building byte buffers, and the compiler handles it efficiently.

Preallocating Capacity to Avoid Reallocations

When you know in advance how many elements a slice will eventually hold, preallocating the capacity with make eliminates intermediate reallocations:

// Without preallocation — potential for multiple reallocations
var result []int
for i := 0; i < 10000; i++ {
    result = append(result, compute(i))
}
// With preallocation — one allocation
result := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
    result = append(result, compute(i))
}

The second version allocates a backing array large enough for 10,000 elements upfront. All append calls happen within the existing capacity, so no further copying or garbage collection is triggered.

This matters most in hot loops. Without preallocation, the slice grows several times, and each growth copies all elements so far to a new array. The garbage collector then has to clean up the discarded arrays. Preallocation turns O(n²) copy work into O(n).

Common Pitfalls and How to Avoid Them

Accidentally modifying a shared backing array

When you create a sub‑slice b := a[:2] from a, both share the same backing array. If you then append to b and the backing array has extra capacity beyond len(b), the appended value overwrites the array location that a also sees:

a := []int{1, 2, 3, 4}
b := a[:2]             // b = [1 2], len=2, cap=4
b = append(b, 99)      // fits in capacity
fmt.Println("a:", a)   // [1 2 99 4] — element at index 2 overwritten
fmt.Println("b:", b)   // [1 2 99]

If you intend to keep the original slice independent, force a copy before appending:

a := []int{1, 2, 3, 4}
b := make([]int, len(a[:2]))
copy(b, a[:2])
b = append(b, 99)
fmt.Println("a:", a) // [1 2 3 4] — unchanged

Sub‑slices share memory until capacity is exceeded:

A sub‑slice a[low:high] always has the same backing array as a and a capacity of cap(a) - low. Any append that stays within that capacity will modify elements visible to a. When you need isolation, use copy or a full slice expression a[low:high:max] to restrict the capacity of the sub‑slice.

Using append for deletion

A common idiom removes an element at index i by appending the slice before and after the element:

s := []int{10, 20, 30, 40}
i := 1 // remove element at index 1 (value 20)
s = append(s[:i], s[i+1:]...)
fmt.Println(s) // [10 30 40]

This works because append takes s[:i] and then appends all elements from s[i+1:]. It is concise but comes with the same shared‑array caveat: the backing array may still hold the old value at the end, now beyond the slice’s length. That is usually harmless, but if you store pointers, you might want to zero the last element to help the garbage collector.

Growth Strategy and Performance

The exact growth algorithm is an implementation detail and not part of the Go specification. The Go runtime typically grows a slice by doubling its capacity when the slice is small, and then by a smaller percentage (roughly 25%) for larger slices. This amortizes the cost of repeated appends so that adding N elements costs O(N) total memory moves, not O(N²).

From a user’s perspective, the takeaway is:

  • Use append without worrying about the growth factor — it is efficient for most workloads.
  • When you know the final size, preallocate with make([]T, 0, desiredCap) to avoid all intermediate allocations and copies.
  • If you are building a slice from many small appends inside a critical loop, benchmarking with and without preallocation is the only way to be sure of the impact.

Summary

The single most important lesson about append is that it always returns a new slice, and you must capture that return value. Whether the returned slice points to the same backing array or a fresh one depends on capacity — and that distinction is what causes both the performance benefits and the shared‑memory surprises that trip up newcomers.

When you understand that append is a function that constructs a new slice header, and that the header might reuse existing storage, you can reason about when slices stay independent and when they silently overlap. Combined with deliberate capacity planning through make, append lets you build dynamic collections without ever touching manual memory management.