The slice copy trap
You're debugging a production issue where a cache entry changes value without any code touching the cache. The culprit is a slice copy that wasn't a copy at all. You passed a slice to a function, the function modified it, and the original data shifted. In Go, slices are not arrays. They are descriptors that point to arrays. Copying a slice copies the descriptor, not the data. This design makes slices fast and flexible, but it means you must be intentional about copying data when you need isolation.
What a slice actually is
A slice is a lightweight struct. It holds three fields: a pointer to the underlying array, a length, and a capacity. The pointer tells the runtime where the data lives in memory. The length tells you how many elements are valid in the slice. The capacity tells you how many elements fit in the underlying array.
When you assign one slice to another, you copy the pointer, length, and capacity. Both slices now point to the same array. Modifying an element through either slice modifies the shared array. To get independent data, you need a new array and a new slice pointing to it.
Think of a slice like a view into a database table. The view describes which rows to show, but the rows live in the table. Copying the view gives you another description of the same rows. Changing a row through one view changes it for everyone. To get a private copy, you need to duplicate the rows into a new table.
Copying with the built-in function
The built-in copy function is the standard way to duplicate a slice. It takes a destination slice and a source slice, and moves elements from the source to the destination. You must allocate the destination slice first.
Here's the simplest way to copy a slice: allocate a new slice with the same length, then copy the elements.
func main() {
// original points to an array with three elements.
original := []int{1, 2, 3}
// make allocates a new underlying array.
// The new slice has the same length as original.
copySlice := make([]int, len(original))
// copy moves elements from original to copySlice.
// It returns the number of elements copied.
copied := copy(copySlice, original)
// copied is 3, not the slice itself.
fmt.Println(copied) // prints: 3
}
The copy function is safe and explicit. It handles the length math for you. If the destination is smaller than the source, it copies as many elements as fit. If the source is smaller, it copies all elements and leaves the rest of the destination zeroed.
Slices are views. Copy the data, not the view.
Walkthrough: allocation and iteration
At runtime, make([]int, len(original)) asks the allocator for a new block of memory. The allocator returns a pointer to a fresh array. The new slice header stores this pointer, the length, and the capacity.
The copy function iterates over the source slice and writes values into the destination. The loop runs up to the minimum of the two lengths. Since we sized the destination exactly, all elements move. The original slice still points to its old array. The new slice points to the new array. They are now independent.
If you modify an element in copySlice, the change stays in the new array. The original slice is unaffected. This isolation is what you need when you want to mutate data without side effects.
Trust copy. It handles the length math for you.
Real-world scenario: defensive returns
Public functions should return defensive copies to protect internal state. If you return a slice that points to internal data, the caller can mutate that data. This breaks encapsulation and leads to subtle bugs.
Here's how to return a safe copy from a package function.
// internalTags holds the package's private tag list.
var internalTags = []string{"go", "backend", "systems"}
// GetTags returns a defensive copy of the tags.
// Callers can modify the returned slice without affecting internal state.
func GetTags() []string {
// Allocate a new slice for the copy.
result := make([]string, len(internalTags))
// Copy data into the new slice.
copy(result, internalTags)
return result
}
The GetTags function allocates a new slice and copies the data. The caller receives a slice that points to a separate array. Modifying the returned slice does not touch internalTags.
Convention aside: accept interfaces, return structs. When you return a slice, return the concrete type, not an interface. This keeps the API clear and avoids unnecessary allocations. Also, public names start with a capital letter. GetTags is public. internalTags is private. The naming convention signals visibility without keywords.
Defensive copying protects your invariants.
Pitfalls: shallow copies and capacity leaks
The copy function copies elements, but it does not deep copy. If your slice contains pointers, copying the slice copies the pointers, not the objects they point to.
Here's a slice of pointers. Copying it gives you a new array of pointers, but those pointers still point to the same objects.
type User struct {
Name string
}
func main() {
// users is a slice of pointers to User structs.
users := []*User{{Name: "Alice"}, {Name: "Bob"}}
// copySlice has a new array, but the pointers are shared.
copySlice := make([]*User, len(users))
copy(copySlice, users)
// Modifying the struct through the pointer affects the original.
copySlice[0].Name = "Alicia"
// users[0] is also modified.
fmt.Println(users[0].Name) // prints: Alicia
}
The copy is shallow. The array is new, but the data is shared. If you need a deep copy, you must iterate and copy each element manually. For structs with pointers, you might need a recursive copy function.
Another pitfall is the return value of copy. The function returns the number of elements copied, not the destination slice. If you write newSlice := copy(dest, src), the compiler rejects this with cannot use copy(dest, src) (value of type int) as []int value in assignment. You must assign the destination slice itself.
A copy of pointers is still a copy of pointers.
The append idiom
Some developers use append to copy a slice. The idiom looks like this: copy := append([]int(nil), original...). This works. It allocates a new slice and appends all elements from the source.
Here's the idiom in action.
func main() {
original := []int{1, 2, 3}
// append with a nil slice allocates a new slice.
// The variadic expansion copies all elements.
copySlice := append([]int(nil), original...)
// copySlice is independent.
copySlice[0] = 99
fmt.Println(original[0]) // prints: 1
}
The append function sees a nil slice with zero capacity. It allocates a new array with enough space for the elements. It copies the elements and returns the new slice. The result is a full copy.
This idiom is concise. It's a one-liner. It's less obvious than copy. Use it if you prefer brevity and your team understands the pattern. The copy function is more readable for beginners. It makes the allocation and copy steps explicit.
Convention aside: gofmt is mandatory. Don't argue about indentation or spacing. Let the tool decide. Most editors run gofmt on save. Focus on logic, not formatting.
Brevity is good. Clarity is better.
Capacity and appending
The copy function copies elements, not capacity. If you copy a slice and then append to the copy, the behavior depends on the copy's capacity.
If you allocate the copy with make([]T, len(src)), the capacity equals the length. Appending to the copy triggers a reallocation. The copy gets a new array. The original is safe.
If you allocate the copy with extra capacity, make([]T, len(src), cap(src)), the copy has room for more elements. Appending to the copy reuses the underlying array. This is safe because make allocates a new array. The copy and original have different arrays.
The danger comes from reslicing. If you create a subslice and copy it, you might share capacity. Always check the capacity if you plan to append.
Here's a safe pattern for copying with capacity preservation.
func main() {
// original has extra capacity.
original := make([]int, 3, 10)
for i := range original {
original[i] = i
}
// copySlice has the same length and capacity.
// make allocates a new array, so no sharing occurs.
copySlice := make([]int, len(original), cap(original))
copy(copySlice, original)
// Appending to copySlice stays within capacity.
// The new array is independent of original.
copySlice = append(copySlice, 99)
fmt.Println(len(copySlice), cap(copySlice)) // prints: 4 10
}
The make call with capacity ensures the copy has room for future appends. The copy function fills the elements. The arrays are separate. Appending is safe.
Capacity is a detail. Manage it when you need performance.
Decision: choosing the right approach
Use the built-in copy function when you need a safe, readable copy of a slice with a known length. Use append with a zero-length slice when you prefer a concise one-liner and your team is comfortable with the idiom. Use a manual loop when you need a deep copy of a slice containing pointers or complex structs. Use the original slice when you want to share data efficiently and the caller is trusted not to mutate it. Use slice reslicing when you need a view into a portion of the data without allocating new memory.