How to Append to a Slice in Go

Use the built-in `append()` function to add elements to a slice, which automatically handles capacity expansion and returns a new slice header.

How to Append to a Slice in Go

You're processing a stream of log entries. You define a slice to buffer the lines, loop through the input, and call append for each entry. The program finishes without errors. You print the slice at the end and get an empty result. The data vanished.

This happens because append does not mutate the slice variable in place. It returns a new slice header. If you ignore the return value, the original variable still points to the old state. The first rule of Go slices is that append is a function that returns a result, not a method that modifies its argument. You must assign the result back to the variable to see the change.

The slice header and append mechanics

A slice in Go is not the data itself. It is a lightweight descriptor that points to an underlying array. The slice header contains three fields: a pointer to the array, the current length, and the capacity. The length is the number of elements currently in the slice. The capacity is the number of elements from the pointer to the end of the underlying array.

When you call append, the runtime checks the capacity. If the length is less than the capacity, append writes the new element into the next slot, increments the length, and returns a new slice header with the updated length. The underlying array stays the same.

If the length equals the capacity, there is no room. append allocates a new, larger array, copies the existing elements, writes the new element, and returns a slice header pointing to the new array. The original slice header remains unchanged. This design keeps slices cheap to copy and pass by value. The data flow is explicit because the function returns the updated descriptor.

Minimal example

Here's the basic pattern: create a slice, append values, and always reassign the result.

package main

import "fmt"

// main demonstrates basic append usage and the return value requirement.
func main() {
	// Start with a slice literal.
	// The compiler infers the type as []int.
	nums := []int{1, 2, 3}

	// append returns a new slice header.
	// Assigning back to nums updates the pointer and length.
	nums = append(nums, 4)

	// Multiple values can be appended in a single call.
	// The runtime handles the writes sequentially.
	nums = append(nums, 5, 6)

	fmt.Println(nums)
}

The compiler rejects type mismatches immediately. If you try to append a string to a slice of integers, you get cannot use "text" (untyped string constant) as int value in argument to append. The type system prevents data corruption at compile time.

Runtime behavior and growth

At compile time, append is a built-in function. The compiler knows its signature and optimizes calls based on the element types. At runtime, the slice grows according to a strategy that balances allocation overhead and memory usage.

Go does not simply double the capacity every time. For small slices, the growth is aggressive to reduce the number of allocations. For larger slices, the growth factor approaches 1.25 to avoid wasting memory. This means appending to a slice is amortized O(1). You don't need to calculate the growth factor manually. The runtime handles the allocation and copying efficiently.

Appending slices with the spread operator

When you need to merge two slices, use the spread operator to unpack the source slice.

package main

import "fmt"

// main demonstrates appending one slice to another using the spread operator.
func main() {
	first := []int{1, 2}
	second := []int{3, 4, 5}

	// The ... operator unpacks second into individual arguments.
	// append copies elements from second into first.
	combined := append(first, second...)

	fmt.Println(combined)
}

The ... syntax tells the compiler to pass the elements of second as separate arguments to append. This is efficient because append can copy the elements directly into the destination array if there is capacity. No intermediate slice is created.

Realistic usage with pre-allocation

When you know the final size, pre-allocate with make to skip reallocations.

package main

import "fmt"

// collectIDs simulates gathering IDs with pre-allocation.
func collectIDs(count int) []string {
	// Pre-allocate capacity to avoid reallocations during the loop.
	// Length is zero; capacity is set to the expected final size.
	ids := make([]string, 0, count)

	for i := 0; i < count; i++ {
		// append adds the ID and updates the slice header.
		// The underlying array has room, so no allocation occurs.
		ids = append(ids, fmt.Sprintf("user-%d", i))
	}

	return ids
}

func main() {
	result := collectIDs(5)
	fmt.Println(result)
}

You can call append on a nil slice. The runtime treats a nil slice as an empty slice with zero length and zero capacity. The first append allocates the initial array. This makes append safe to use without checking for nil. However, if you know the final size, use make to set the capacity upfront. This avoids the geometric growth and repeated copying.

The community expects you to reassign the result of append. Discarding the return value is considered a bug waiting to happen. Unlike error returns where _ is acceptable, ignoring an append result usually means you forgot to update the slice. The convention is clear: capture the return value.

Pitfalls and compiler errors

The most common mistake is calling append without reassignment. The code compiles. The slice stays the same. This is a silent runtime logic error. Always reassign.

Another pitfall is slice aliasing. If two slices share the underlying array, appending to one can affect the other if the capacity is sufficient.

package main

import "fmt"

// main demonstrates slice aliasing risks during append.
func main() {
	// Create a slice with extra capacity.
	// Length is 3, capacity is 5.
	a := []int{1, 2, 3, 0, 0}
	a = a[:3]

	// b shares the underlying array with a.
	// b starts at index 2 and has length 2.
	b := a[2:]

	fmt.Println("Before:", a, b)

	// Appending to a writes into the unused capacity.
	// This overwrites the element that b also sees.
	a = append(a, 99)

	fmt.Println("After:", a, b)
}

In this example, a and b share the same array. Appending 99 to a writes into the slot at index 3. Since b starts at index 2, b sees the new value. If the capacity were exceeded, append would allocate a new array for a, and b would remain safe. This behavior is subtle. Be careful when slicing and appending.

The compiler rejects invalid operations. If you try to append to a map, you get cannot append to type map[string]int. Slices and maps are distinct types. If you try to append a slice without the spread operator, you get cannot use slice (type []int) as type int in argument to append. The type system enforces the correct usage.

Decision matrix

Use append when you need to add elements to a slice dynamically. Use make with capacity when you know the final size to avoid reallocations. Use the ... operator when appending all elements from another slice of the same type. Use a fixed-size array when the size is known at compile time and never changes. Use a map when you need key-based access instead of sequential indexing.

append returns a new header. Assign it back, or the change vanishes.

Where to go next