Last Updated: May 22, 2026
Slices grow and shrink as your program runs, and Go gives you two built-in functions to manage that movement: append adds elements to the end of a slice, and copy moves elements from one slice into another. Both look simple, and most of the time they are. But each one has a quirk that bites learners early: append sometimes hands back a brand-new slice header, and copy only moves as many elements as the shorter side allows. This lesson covers both built-ins, the rules behind their return values, and the way they interact with the backing array that sits underneath every slice.
The most common shape of append takes a slice and one value, and returns the slice with that value tacked onto the end.
Two things are happening here. First, append returns a new slice header. It does not modify the variable cart in place. This is why the call has to be written as cart = append(cart, "Eraser") rather than just append(cart, "Eraser"). We'll come back to the must-reassign rule in its own section, because it trips up nearly every newcomer.
Second, the new element ends up at index len(cart), which is the slot one past the last existing item. If the backing array has room for that slot already, append writes the value into it. If the backing array is full, append allocates a bigger one, copies the existing elements over, and writes the new value into the new array. Either way, the returned slice header points to wherever the data lives after the call.
Starting from a nil slice is fine. append treats nil and an empty slice the same way: both have length 0, so the first append allocates a backing array and writes the value at index 0. A common pattern is to declare an empty slice, then build it up element by element inside a loop.
append is variadic, meaning the second argument is actually a list of values. You can pass any number of items after the slice.
This is just a shorter way to write three separate append calls. The result is identical, but the variadic form is easier to read and lets the runtime reserve enough room in one step if a reallocation is needed.
The values you pass have to match the slice's element type. Trying to append an int to a []string is a compile error, not a runtime one.
...Appending a single value at a time works, but what if you already have a slice of items and want to append all of them to another slice? You don't have to loop. Go's spread syntax, ..., expands a slice into its individual elements at the call site.
The three dots after extras tell append to treat the slice as if you'd typed its elements out one by one. Without the ..., the compiler reports cannot use extras (type []string) as type string in argument to append. The error is exact: append was looking for individual string values, and you handed it a whole slice.
The two slices must have the same element type. You can't spread a []int into a []float64 even though int converts to float64 in arithmetic. Slices are not implicitly convertible.
append(morning, evening...) is the idiomatic way to concatenate two slices in Go. There's no concat operator and no + for slices.
append returns a slice. It does not change the slice header you passed in. This is the rule that catches more learners than any other part of slices.
The compiler refuses to build this code with the message append(cart, "Pen") evaluated but not used. Go is doing you a favor here: throwing away the result of append is almost always a bug, so the compiler treats it like one.
But there's a sneakier version where the call compiles, and the bug only shows up at runtime.
Every slice has a length and a capacity. Length is how many elements you can index. Capacity is how many slots the backing array has in total. When you append, Go checks whether there's room in the existing backing array. If there is, the new element goes into the next slot and the slice's length grows by one. If there isn't, Go allocates a bigger backing array, copies the existing elements over, and the new slice points at the new array.
The slice starts with capacity 4. The first four append calls fit in the existing backing array, so the length grows but the capacity stays at 4. The fifth call has nowhere to put the value, so Go allocates a new backing array. The new capacity is 8, double the previous size.
The exact growth factor depends on the Go runtime's implementation. For small slices, capacity roughly doubles each time it has to grow. For larger slices (thousands of elements and up), the growth factor shrinks toward something closer to 1.25x. The point of this strategy is to keep append fast on average. If capacity always grew by exactly one slot, every append would copy the entire slice, making the operation O(n) per call. Doubling means the total work for n appends is proportional to n, not n squared.
append is amortized O(1) per call. Most calls write into the existing backing array. The occasional reallocation copies all existing elements, but it happens rarely enough that the average cost stays constant. If you know the final size, pre-size with make([]T, 0, n) to skip the reallocations entirely.
Here's a diagram showing the two cases side by side. On the left, the backing array has spare capacity and the new element fits without copying. On the right, the array is full, so Go allocates a new one and copies everything over.
The diagram shows why the must-reassign rule matters. On the left, the original slice header is still valid after the append, because the data hasn't moved. On the right, the original header points at the old array, which now has a stale view of the data. Only the returned header from append points at the new array. Forget to reassign and your variable is out of date.
The third append triggers a reallocation because the slice is already at full capacity. The new capacity is 4, and the existing two elements are copied into the new array before the new value is written.
Each reallocation copies every existing element. For small slices this is negligible, but a slice that grows to a million elements through repeated append calls will have copied roughly two million elements in total across all the growth events. Pre-sizing avoids all of it.
copy Built-Incopy moves elements from one slice into another. It returns the number of elements it actually copied.
The first argument is the destination, the second is the source. That order matches what you'd expect from memcpy in C or Array.Copy in C#: destination on the left. Reversing them is a common bug, and the compiler can't catch it because both arguments have the same type.
copy does not grow the destination. It only writes into slots that already exist. If the destination has length 3 and the source has 10 elements, copy writes 3 and stops. The return value tells you exactly how many made it across.
Only the first 3 elements were copied because that's how many slots the destination had. The remaining 40 and 50 were left alone in the source and never touched the destination.
copy is O(n) where n is the number of elements actually copied. It uses an optimized memory move internally for simple types like int or string, so it's faster than a for loop that copies the same elements one at a time.
The number of elements copy writes is the minimum of the two lengths. This is the rule that catches learners off guard.
The destination has 5 slots, but the source only has 2 elements. copy writes 2 and leaves the rest of the destination at its zero value (empty strings here). The return value is 2, the minimum of len(target) and len(source).
The other direction works the same way.
copy writes 4 elements because the destination has 4 slots. The extra elements in the source are ignored. This is the behavior you want when filling a fixed-size buffer from a larger stream.
copyA common reason to use copy is to make an independent copy of a slice. Assignment alone doesn't do this: b := a copies the slice header, not the backing array, so both variables still see the same underlying data.
make([]string, len(original)) allocates a fresh backing array sized to match the source. copy fills it with the elements. After that, clone is fully independent: writing to clone[0] has no effect on original.
The pattern make plus copy is the standard way to clone a slice when you need to mutate one copy without disturbing the other. On Go 1.21 or later, slices.Clone does the same thing in one line.
snapshot keeps the prices as they were before the modifications, because it has its own backing array. Even an append that triggers a reallocation on cart can't reach back into the snapshot.
copy With Overlapping Slicescopy is safe when the source and destination share a backing array, even when the ranges overlap. The runtime handles the overlap correctly without you having to think about copy direction.
The source items[:4] is ["A", "B", "C", "D"], and the destination items[1:] starts at index 1. The two ranges overlap from index 1 through index 3. Despite the overlap, the result is correct: each element shifts one slot to the right.
The reverse direction also works.
Source is items[1:], destination is items[:4]. Each element shifts one slot to the left, and the trailing E stays put because nothing wrote over it. This pattern is the foundation of the in-place "delete from a slice" trick.
append and copy Interact With the Backing ArrayThe two built-ins look unrelated, but they share one underlying truth: every slice operation eventually touches a backing array, and what's safe versus surprising depends on whether multiple slice headers point at the same one.
first and last are two views into the same backing array as full. first has length 3 and inherits capacity 5 from index 0, so when you append 99, it goes into index 3 of the shared array, no reallocation needed. That index is the first element of last, which is why last now starts with 99. The same index is full[3], which is also showing 99.
The lesson here is that append writes into the existing backing array whenever it can. Slice headers pointing at the affected indices see the change. Some patterns lean on this deliberately (in-place deletes and shifts), and other times the sharing causes subtle bugs. The rule to remember: an append that fits in the current capacity is a write into shared memory.
This time append adds three elements to a slice that started with length 2 and capacity 3. The first 99 fits at index 2 of the existing backing array, but then the slice needs to grow beyond capacity 3. Go allocates a new backing array, copies the data over, and writes the remaining two 99s into the new array. After that, view points at the new array and full still points at the original. They no longer share storage.
The same call, run on a slice with a larger starting capacity, would have written all three 99s into the shared array and reached back into full. Same code, different result. This is exactly the situation the must-reassign rule was designed to make visible: whichever case applies, the new slice header is the one that's safe to read from, and you only get it if you assign the result.
Pre-sizing avoids the surprising aliasing behavior, not just the copy overhead. make([]T, 0, n) followed by n appends keeps the slice in one backing array for its entire lifetime, which is predictable and fast.