The box and the window
You write a function to filter a list of user IDs. You pass the list in, modify it, and expect the caller to see the result. The compiler rejects your code because you passed an array where a slice was expected. You change the type, and it compiles. You run it, and the caller sees the modifications. Then you add one more item inside the function using append, and suddenly the caller doesn't see that new item. The data structure is hiding something from you.
This confusion happens because Go has two distinct collection types that look similar but behave differently. Arrays are fixed-size containers where the size is part of the type. Slices are dynamic views over underlying arrays that track length and capacity separately. Arrays copy their data when passed around. Slices share their data.
Understanding the difference prevents subtle bugs where functions silently fail to update caller data, or where memory usage spikes because you accidentally copied a massive dataset.
Arrays are concrete. Slices are views.
An array in Go is a value type with a fixed size. The size is part of the type definition. [3]int is a completely different type from [4]int. You cannot assign one to the other. You cannot change the size of an array after you create it. If you pass an array to a function, Go copies the entire array. This ensures the function cannot mutate the caller's data without the caller explicitly passing a pointer. The trade-off is performance. Copying a large array is expensive.
A slice is a lightweight descriptor. It points to an underlying array and tracks how much of that array you are currently using. A slice has three fields: a pointer to the underlying array, a length, and a capacity. Length is the number of elements you can access. Capacity is the number of elements available in the underlying array starting from the slice's first element. Slices are passed by value, but the value is just the descriptor. The underlying data is shared. When you pass a slice to a function, Go copies the descriptor. The copy points to the same array. Changes to elements are visible to the caller. Changes to the slice header, like length, are not visible unless you return the slice.
Think of an array as a row of lockers with a fixed number of slots. Once built, you cannot add or remove slots. A slice is a label you stick on the lockers. The label says "Start at locker 5, use 3 lockers." You can slide the label, extend it, or shrink it without moving the lockers. Multiple labels can point to the same lockers. If you paint a locker, everyone with a label covering that locker sees the paint.
Minimal example: types and slicing
Here's the simplest distinction: arrays are typed by size, slices are dynamic views.
package main
import "fmt"
func main() {
// Array: size is part of the type. Fixed at compile time.
// [3]int is distinct from [4]int.
var arr [3]int = [3]int{1, 2, 3}
// Slice: dynamic view. No size in the type.
// []int can hold any number of ints.
var slice []int = []int{1, 2, 3}
// Slicing an array creates a slice.
// This extracts a view from index 1 up to (but not including) index 3.
// The result shares the underlying array with arr.
sub := arr[1:3]
fmt.Printf("Array type: %T\n", arr)
fmt.Printf("Slice type: %T\n", slice)
fmt.Printf("Sub-slice: %v\n", sub)
}
The output shows the type difference clearly. The array type includes the size. The slice type does not. The sub-slice is also a slice, not an array. Slicing always produces a slice.
What happens under the hood
At compile time, Go treats [3]int and [4]int as unrelated types. You cannot assign one to the other. The compiler enforces this strictly. If you try to pass a [4]int to a function expecting [3]int, the compiler rejects the program with a type mismatch error like cannot use arr (variable of type [4]int) as [3]int value in argument. This strictness catches bugs early. You cannot accidentally pass the wrong size.
At runtime, a slice is a small struct containing three fields: a pointer to the underlying array, the length, and the capacity. The pointer is a memory address. The length and capacity are integers. When you pass a slice to a function, Go copies this struct. The copy has the same pointer, length, and capacity. Both the original and the copy point to the same underlying array.
Modifying an element through the copy modifies the array. The original sees the change. Modifying the length or capacity of the copy does not affect the original. The original still has its old length. This is why append often requires you to capture the return value. append may change the length. If the capacity is insufficient, append allocates a new array and updates the pointer. The original slice still points to the old array.
Convention aside: The slice header structure is an implementation detail. Go provides reflect.SliceHeader, but you should not use it to manipulate slices. The layout can change between versions, and using it bypasses safety checks. Use built-in functions like len, cap, and append instead.
Realistic example: processing a batch
Here's a realistic scenario: a function that processes a batch of items. It modifies existing items and adds a new one.
package main
import "fmt"
// ProcessBatch demonstrates how append interacts with capacity.
// It modifies elements in place and appends a new item.
func ProcessBatch(items []int) []int {
// Modify existing elements. The caller sees this change
// because the slice shares the underlying array.
for i := range items {
items[i] *= 2
}
// Append adds a new element.
// If capacity allows, the underlying array is reused.
// The caller might or might not see this new element
// depending on whether the slice header is updated.
items = append(items, 99)
// Return the updated slice so the caller gets the new length.
// Always capture the return value of append.
return items
}
func main() {
// Create a slice with extra capacity.
// Length is 3, capacity is 5.
// The underlying array has space for 2 more elements.
data := make([]int, 3, 5)
data[0] = 1
data[1] = 2
data[2] = 3
fmt.Printf("Before: %v (len=%d, cap=%d)\n", data, len(data), cap(data))
// Pass to function.
result := ProcessBatch(data)
fmt.Printf("After call: %v (len=%d, cap=%d)\n", data, len(data), cap(data))
fmt.Printf("Result: %v (len=%d, cap=%d)\n", result, len(result), cap(result))
}
The output reveals the mechanics. The data slice sees the doubled values 2, 4, 6. It does not see the 99. The result slice sees 2, 4, 6, 99. Both slices share the same underlying array. The data slice has length 3, so it only sees the first three elements. The result slice has length 4, so it sees all four. The append operation reused the underlying array because the capacity was sufficient. The function returned the new slice header with the updated length.
If you had not returned the slice, the caller would have lost the new element. The caller would also have lost the new length. This is a common pitfall. append returns a slice. You must capture it.
Convention aside: Always capture the return value of append. The function signature is func append(slice []Type, elems ...Type) []Type. It returns a slice. Ignoring the return value is a logic error. The compiler does not warn you about unused return values for append because it is a built-in, but the behavior is undefined without capturing the result.
Pitfalls and hidden bugs
Slice aliasing causes subtle bugs. When you create a subslice, it shares the underlying array with the original. Modifying the subslice modifies the original. This is usually intended. Problems arise when you append to a subslice and the capacity allows growth. The subslice grows into the original's space. The original might see overwritten data or garbage values if you do not adjust its length.
Consider a loop that processes chunks of data. You create a subslice for each chunk. You append to the subslice. If the capacity is large, the subslice overwrites data in the original array that other chunks depend on. The fix is to use make to allocate a new slice for each chunk, or to ensure the subslice has its own capacity by copying the data.
Nil slices and empty slices behave differently in some contexts. A nil slice has a null pointer. An empty slice has a pointer to an array but length zero. len and cap return zero for both. Iteration works the same. The difference shows up in JSON marshaling. json.Marshal produces null for a nil slice and [] for an empty slice. API contracts often care about this distinction. If your API expects an empty array, initialize the slice with make([]T, 0) or []T{}. If you declare var s []T, it is nil.
Out of bounds access panics. Accessing an index beyond the length causes a runtime panic. The error message is runtime error: index out of range [5] with length 3. This happens at runtime, not compile time. The compiler cannot always know the length. Use len to check bounds before accessing, or use range loops to avoid manual indexing.
Arrays are value types. Copying an array copies all elements. This can be expensive. If you have a large array, passing it to a function copies the entire array. Use a slice or a pointer to the array instead. Slices are cheap to copy. Pointers to arrays are cheap to copy. The choice depends on whether you need value semantics. If you need the function to be unable to mutate the data, use an array or a slice with a copy. If you need performance, use a slice.
Decision: when to use arrays vs slices
Use an array when the size is fixed and known at compile time, such as a matrix of coordinates or a fixed set of flags. Use an array when you need value semantics, ensuring the function cannot mutate the caller's data without explicit effort. Use an array inside a struct when the data is small and you want the struct to be fully self-contained, copying all data on assignment.
Use a slice when the collection size changes at runtime, such as reading lines from a file or accumulating results. Use a slice when you need to pass a collection to a function efficiently without copying the entire dataset. Use a slice when you need to share data between multiple parts of the program. Use a slice when the size is determined by input or computation.
Use make to allocate a slice with a specific length and capacity when you know the approximate size in advance to avoid reallocations. Use a slice literal []int{1, 2, 3} when initializing a small, fixed set of values inline. Use a subslice when you need to view a portion of a larger dataset without copying.
Arrays are value types. Slices are views. Pick the one that matches your mutation needs.