Understanding the core difference
When you start working with Go, one of the first hurdles is understanding the distinction between arrays and slices. Both store collections of elements, but they behave very differently—and in production systems handling thousands of requests, the wrong choice can lead to unexpected memory usage or data corruption. Arrays vs slices in Go: a practical guide for developers explores these differences to help you avoid surprises that surface only under load.
The fundamental distinction is straightforward: an array has a fixed length that's part of its type definition, while a slice is a dynamic view into an underlying array. This sounds academic until you're debugging why your API is holding onto memory it shouldn't, or why modifying one slice inadvertently changed another.
Arrays have fixed length as part of the type
In Go, when you declare an array, you specify its length, and that length becomes immutable and part of the type itself. Two arrays of different lengths are different types entirely—you cannot assign one to the other, even if they hold the same element type.
// Fixed-length arrays
var ids [5]int // Array of exactly 5 integers
var names [3]string // Array of exactly 3 strings
ids = [5]int{1, 2, 3, 4, 5}
// ids = [3]int{1, 2, 3} // Compile error: mismatched types
userIDs := [10]int{1, 2, 3} // Length is fixed at 10
When you pass an array to a function, Go copies the entire array. For small arrays this is fine, but for large ones it wastes memory and CPU cycles. Consider a function that processes a batch of IDs from your database:
func processIDs(ids [1000]int) {
// Go copies all 1000 integers here
for _, id := range ids {
// work with id
}
}
var batch [1000]int
// ... populate batch ...
processIDs(batch) // Expensive: full copy
Arrays are useful when you need a fixed collection with predictable, bounded size—think of storing a fixed number of coordinates, or a small lookup table. But for most real-world scenarios in APIs and services, you'll reach for slices instead.
Slices reference an underlying array
A slice is a lightweight descriptor that points to an underlying array, tracking three things: a pointer to the array data, a length, and a capacity. When you pass a slice to a function, you pass this small descriptor, not the entire data set. This is why slices are the default choice in Go.
// Creating a slice
var ids []int // nil slice, length 0, capacity 0
ids = []int{1, 2, 3, 4, 5} // Slice literal
ids = make([]int, 5) // Length 5, capacity 5, zero-filled
ids = make([]int, 5, 10) // Length 5, capacity 10
// Slicing an array
arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
ids = arr[2:7] // Slice from index 2 to 7 (exclusive)
When you pass a slice to a function, the cost is constant regardless of how many elements it contains. The function receives a copy of the slice descriptor, but both the caller and the function operate on the same underlying array:
func processIDs(ids []int) {
// Only the descriptor is copied, not the data
for i, id := range ids {
ids[i] = id * 2
}
}
batch := []int{1, 2, 3, 4, 5}
processIDs(batch)
fmt.Println(batch) // [2 4 6 8 10] - modified!
This shared memory is a feature, not a bug. It lets functions modify the slice's contents efficiently. But it also means you need to be careful—modifying a slice inside a function affects the caller's data.
Sub-slices share memory
One of the trickiest aspects of slices is that when you create a sub-slice (a slice of a slice), both reference the same underlying array. This catches many developers off guard, especially in production code where data flows through multiple processing stages.
data := []int{10, 20, 30, 40, 50}
subset := data[1:4] // References same underlying array
fmt.Println(subset) // [20 30 40]
subset[0] = 99
fmt.Println(data) // [10 99 30 40 50] - original modified!
Consider a typical API handler that filters request data and passes it downstream. If you're not careful, modifications in one layer affect data in another:
type Request struct {
UserIDs []int
}
func FilterActiveUsers(req Request) []int {
active := req.UserIDs[0:len(req.UserIDs)] // Sub-slice
// Modify active ...
return active
}
func main() {
req := Request{UserIDs: []int{1, 2, 3, 4, 5}}
result := FilterActiveUsers(req)
result[0] = 999
fmt.Println(req.UserIDs) // [999 2 3 4 5] - unexpected!
}
This behavior becomes especially problematic when you're caching, storing slices in structs, or passing them between goroutines. A modification intended for temporary processing data silently corrupts the original.
Use copy() to detach a slice
When you need a slice that's independent from the original—when you want to avoid shared memory—use the built-in copy() function. This creates a new underlying array and copies the data, severing the connection.
original := []int{10, 20, 30, 40, 50}
independent := make([]int, len(original))
copy(independent, original)
independent[0] = 999
fmt.Println(original) // [10 20 30 40 50] - untouched
fmt.Println(independent) // [999 20 30 40 50]
The copy() function takes a destination and source slice, copies as many elements as fit in the destination, and returns the number of elements copied. It's safe to use even if the destination is smaller than the source.
source := []int{1, 2, 3, 4, 5}
dest := make([]int, 3)
n := copy(dest, source)
fmt.Println(dest) // [1 2 3]
fmt.Println(n) // 3 - copied 3 elements
In production APIs, you typically use copy() when you need to:
- Extract a sub-slice that should be modified independently
- Return user-controlled data without risk of internal modification
- Cache a snapshot of data for later comparison
- Pass data safely between goroutines
Consider a real scenario: a cache lookup function that should return data the caller can safely modify without affecting future cache hits:
type Cache struct {
data []int
}
func (c *Cache) Get() []int {
result := make([]int, len(c.data))
copy(result, c.data)
return result // Caller can't modify the cache
}
Common mistakes
Mistake 1: Assuming slices are independent by default. New developers often expect that assigning one slice to another creates a copy. It doesn't—it just copies the descriptor, and both slices reference the same data.
a := []int{1, 2, 3}
b := a // b points to the same data as a
b[0] = 99
fmt.Println(a) // [99 2 3] - surprise!
Mistake 2: Forgetting that append() may reallocate. When a slice reaches its capacity and you append, Go allocates a new array and copies the data. The old slice still points to the old array, but any new data goes into the new one. If you're relying on sub-slices to reflect changes, you'll get inconsistencies.
s := make([]int, 1, 2)
s[0] = 10
sub := s[0:1]
s = append(s, 20) // Reallocates because capacity was 2
s = append(s, 30) // Reallocates again; now new array
sub[0] = 99
fmt.Println(s) // [99 20 30]? No: [20 30] - they're separate
Mistake 3: Not copying when storing request data. In HTTP handlers, you might store a slice from the request context. If you don't copy it and the underlying array gets reused or modified, your stored data becomes corrupted.
Key takeaways
- Arrays have a fixed length that's part of their type; slices are dynamic views into an underlying array.
- Passing an array to a function copies the entire array; passing a slice copies only the descriptor (pointer, length, capacity).
- Sub-slices and slice assignments share the same underlying array—modifications in one affect the other.
- Use
copy()to create an independent slice with its own data when you need isolation. - Be especially careful with slices in concurrent code, caching, and request handling where data isolation matters.
- Profile your code if you're unsure whether shared memory is causing issues—sometimes the simplicity of shared memory is the right choice.






Comments
Loading comments…