Learning Go: Never Use Arrays
Introduction
If you're new to Go, you might be surprised to learn that arrays are rarely used in idiomatic Go code. Despite being a fundamental data structure in most programming languages, Go arrays have significant limitations that make them impractical for most real-world scenarios.
In this article, we'll explore why arrays should almost never be used in Go, understand the critical differences between arrays and slices, and learn when slices are the better choice (which is nearly always).
What Are Arrays in Go?
In Go, an array is a fixed-size, numbered sequence of elements of a single type. The size is part of the array's type, which leads to some surprising limitations.
package main
import "fmt"
func main() {
// Array declaration - size is part of the type
var numbers [5]int
numbers[0] = 10
numbers[1] = 20
// Array literal
fruits := [3]string{"apple", "banana", "orange"}
fmt.Println(numbers) // [10 20 0 0 0]
fmt.Println(fruits) // [apple banana orange]
}Seems straightforward, right? Let's see why this becomes problematic.
The Fundamental Problem: Size is Part of the Type
The most critical issue with Go arrays is that the size is part of the type definition. This means [3]int and [5]int are completely different, incompatible types.
package main
import "fmt"
func printArray(arr [3]int) {
fmt.Println(arr)
}
func main() {
arr1 := [3]int{1, 2, 3}
arr2 := [5]int{1, 2, 3, 4, 5}
printArray(arr1) // ✅ Works
// printArray(arr2) // ❌ Compile error: cannot use arr2 (type [5]int) as type [3]int
}Problem: You can't write generic functions that work with arrays of different sizes. You'd need a separate function for every array size!
Problem #1: Arrays Are Passed By Value
In Go, arrays are value types, not reference types. When you pass an array to a function, the entire array is copied.
package main
import "fmt"
func modifyArray(arr [5]int) {
arr[0] = 999
fmt.Println("Inside function:", arr)
}
func main() {
numbers := [5]int{1, 2, 3, 4, 5}
modifyArray(numbers)
fmt.Println("Original array:", numbers)
}Output:
Inside function: [999 2 3 4 5]
Original array: [1 2 3 4 5]Problem: The modification didn't affect the original array because a full copy was made. For large arrays, this is both inefficient (memory waste) and confusing (unexpected behavior).
Workaround: Passing Pointers (Still Not Great)
func modifyArray(arr *[5]int) {
arr[0] = 999
}
func main() {
numbers := [5]int{1, 2, 3, 4, 5}
modifyArray(&numbers)
fmt.Println(numbers) // [999 2 3 4 5]
}This works, but now you're stuck with the fixed size problem again. You need different functions for *[5]int, *[10]int, etc.
Problem #2: No Built-in Growth
Arrays have a fixed size. You cannot append elements or resize them.
func main() {
arr := [3]int{1, 2, 3}
// arr = append(arr, 4) // ❌ Compile error: first argument to append must be slice
// To "add" an element, you'd need to create a new array
newArr := [4]int{arr[0], arr[1], arr[2], 4}
fmt.Println(newArr)
}Problem: This is tedious, error-prone, and inefficient.
Problem #3: Cannot Return Different Sizes
Since array size is part of the type, functions must return a specific size.
// This function can ONLY return arrays of size 3
func getNumbers() [3]int {
return [3]int{1, 2, 3}
}
// You can't write a function that returns arrays of dynamic size
// func getDynamicArray(size int) [size]int { // ❌ Invalid syntax
// ...
// }Problem: You can't write flexible functions that return different-sized collections.
Problem #4: Incompatible with Standard Library
Most of Go's standard library functions work with slices, not arrays.
import (
"fmt"
"sort"
)
func main() {
arr := [5]int{5, 2, 8, 1, 9}
// sort.Ints(arr) // ❌ Compile error: cannot use arr (type [5]int) as type []int
// You'd need to convert to a slice
sort.Ints(arr[:])
fmt.Println(arr)
}Problem: Arrays don't integrate well with the Go ecosystem.
The Solution: Use Slices
Slices are the answer to all these problems. A slice is a flexible, dynamic view into an array.
package main
import "fmt"
func main() {
// Slice declaration (no size specified)
var numbers []int
// Slice literal
fruits := []string{"apple", "banana", "orange"}
// Appending to a slice
numbers = append(numbers, 1, 2, 3)
fmt.Println(numbers) // [1 2 3]
fmt.Println(fruits) // [apple banana orange]
}Slices vs Arrays: Key Differences
| Feature | Array | Slice |
|---|---|---|
| Size | Fixed, part of type | Dynamic, not part of type |
| Type | [N]Type | []Type |
| Passing | Copied by value | Reference to underlying array |
| Resizable | No | Yes (with append) |
| Standard lib | Limited support | Full support |
| Memory | Value type | Reference type (header + array) |
Why Slices Are Better
1. Generic Functions
package main
import "fmt"
// Works with slices of ANY length
func printSlice(s []int) {
fmt.Println(s)
}
func main() {
slice1 := []int{1, 2, 3}
slice2 := []int{1, 2, 3, 4, 5, 6, 7, 8}
printSlice(slice1) // ✅ Works
printSlice(slice2) // ✅ Works
}2. Pass by Reference (Lightweight)
package main
import "fmt"
func modifySlice(s []int) {
s[0] = 999
fmt.Println("Inside function:", s)
}
func main() {
numbers := []int{1, 2, 3, 4, 5}
modifySlice(numbers)
fmt.Println("Original slice:", numbers) // [999 2 3 4 5]
}Output:
Inside function: [999 2 3 4 5]
Original slice: [999 2 3 4 5]The slice is passed by reference (actually, a copy of the slice header), so modifications affect the original.
3. Dynamic Growth with append
package main
import "fmt"
func main() {
slice := []int{1, 2, 3}
fmt.Printf("Length: %d, Capacity: %d\n", len(slice), cap(slice))
slice = append(slice, 4, 5)
fmt.Printf("Length: %d, Capacity: %d\n", len(slice), cap(slice))
fmt.Println(slice) // [1 2 3 4 5]
}Output:
Length: 3, Capacity: 3
Length: 5, Capacity: 6
[1 2 3 4 5]Slices automatically grow as needed!
4. Built-in Functions and Standard Library
package main
import (
"fmt"
"sort"
"strings"
)
func main() {
// Sorting
numbers := []int{5, 2, 8, 1, 9}
sort.Ints(numbers)
fmt.Println(numbers) // [1 2 5 8 9]
// Strings
words := []string{"banana", "apple", "cherry"}
sort.Strings(words)
fmt.Println(words) // [apple banana cherry]
// Joining
result := strings.Join(words, ", ")
fmt.Println(result) // apple, banana, cherry
}5. Flexible Return Types
package main
import "fmt"
func getNumbers(count int) []int {
result := make([]int, count)
for i := 0; i < count; i++ {
result[i] = i + 1
}
return result
}
func main() {
fmt.Println(getNumbers(3)) // [1 2 3]
fmt.Println(getNumbers(7)) // [1 2 3 4 5 6 7]
}Understanding Slice Internals
A slice is a lightweight structure with three components:
type slice struct {
ptr *Type // Pointer to underlying array
len int // Current length
cap int // Capacity (size of underlying array)
}package main
import "fmt"
func main() {
// Create a slice from an array
arr := [5]int{1, 2, 3, 4, 5}
slice := arr[1:4] // slice points to arr[1], arr[2], arr[3]
fmt.Println("Array:", arr)
fmt.Println("Slice:", slice)
fmt.Printf("Len: %d, Cap: %d\n", len(slice), cap(slice))
// Modifying slice affects the array
slice[0] = 999
fmt.Println("Array after modification:", arr)
}Output:
Array: [1 2 3 4 5]
Slice: [2 3 4]
Len: 3, Cap: 4
Array after modification: [1 999 3 4 5]When Would You Actually Use Arrays?
There are rare cases where arrays make sense:
1. Fixed-Size Data Structures
When the size is truly fixed and meaningful to the type.
type IPv4 [4]byte // IP address is always 4 bytes
type SHA256 [32]byte // SHA-256 hash is always 32 bytes
type Matrix3x3 [3][3]float64 // 3x3 matrix
func parseIP(ip string) IPv4 {
// ...
}2. Performance-Critical Code with Known Size
If you need to avoid heap allocations in performance-critical paths.
func process() {
// Array allocated on stack (faster)
var buffer [1024]byte
// vs slice which might allocate on heap
// buffer := make([]byte, 1024)
}3. Memory Layout Requirements
When you need precise memory layout for system-level programming or CGO.
// For interfacing with C code
type CStruct struct {
Values [10]uint32 // Must be exactly 10 elements
}Rule of thumb: If you're not sure whether to use an array or slice, use a slice.
Common Slice Patterns
Creating Slices
// Nil slice
var s []int
// Empty slice with make
s = make([]int, 0)
// Slice with initial length
s = make([]int, 5) // [0 0 0 0 0]
// Slice with length and capacity
s = make([]int, 5, 10) // len=5, cap=10
// Slice literal
s = []int{1, 2, 3, 4, 5}Appending Elements
slice := []int{1, 2, 3}
// Append single element
slice = append(slice, 4)
// Append multiple elements
slice = append(slice, 5, 6, 7)
// Append another slice
other := []int{8, 9}
slice = append(slice, other...) // Note the ...Copying Slices
original := []int{1, 2, 3}
// Create a copy
copied := make([]int, len(original))
copy(copied, original)
copied[0] = 999
fmt.Println("Original:", original) // [1 2 3]
fmt.Println("Copied:", copied) // [999 2 3]Slicing Slices
numbers := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
fmt.Println(numbers[2:5]) // [2 3 4]
fmt.Println(numbers[:3]) // [0 1 2]
fmt.Println(numbers[7:]) // [7 8 9]
fmt.Println(numbers[:]) // [0 1 2 3 4 5 6 7 8 9]Removing Elements
func remove(slice []int, index int) []int {
return append(slice[:index], slice[index+1:]...)
}
numbers := []int{1, 2, 3, 4, 5}
numbers = remove(numbers, 2) // Remove index 2 (value 3)
fmt.Println(numbers) // [1 2 4 5]Best Practices
1. Always Use Slices for Function Parameters
// Good
func ProcessItems(items []string) {
// ...
}
// Bad
func ProcessItems(items [10]string) {
// ...
}2. Pre-allocate Slices When Size is Known
// Efficient
items := make([]int, 0, expectedSize)
for i := 0; i < expectedSize; i++ {
items = append(items, i)
}
// Less efficient (multiple reallocations)
var items []int
for i := 0; i < 1000; i++ {
items = append(items, i)
}3. Understand Slice Sharing
original := []int{1, 2, 3, 4, 5}
slice1 := original[1:3]
slice2 := original[2:4]
slice1[1] = 999
fmt.Println(original) // [1 2 999 4 5]
fmt.Println(slice1) // [2 999]
fmt.Println(slice2) // [999 4]All three slices share the same underlying array!
4. Use copy to Avoid Unintended Sharing
original := []int{1, 2, 3, 4, 5}
independent := make([]int, len(original))
copy(independent, original)
independent[0] = 999
fmt.Println(original) // [1 2 3 4 5]
fmt.Println(independent) // [999 2 3 4 5]5. Check nil vs Empty Slices
var nilSlice []int
emptySlice := []int{}
madeSlice := make([]int, 0)
fmt.Println(nilSlice == nil) // true
fmt.Println(emptySlice == nil) // false
fmt.Println(madeSlice == nil) // false
// But all have length 0
fmt.Println(len(nilSlice)) // 0
fmt.Println(len(emptySlice)) // 0
fmt.Println(len(madeSlice)) // 0In most cases, treat them the same. Some APIs distinguish between nil and empty slices (like JSON marshaling).
Converting Between Arrays and Slices
Array to Slice
arr := [5]int{1, 2, 3, 4, 5}
// Full array as slice
slice := arr[:]
// Partial array as slice
slice = arr[1:4]Slice to Array (Go 1.17+)
slice := []int{1, 2, 3, 4, 5}
// Convert to array pointer
arr := (*[5]int)(slice)
// Or using type conversion (Go 1.20+)
arr2 := [5]int(slice)Warning: This panics if the slice length doesn't match the array size!
Performance Considerations
Stack vs Heap Allocation
// Array on stack (fast)
func useArray() {
var arr [1000]int
// Use arr...
}
// Slice might allocate on heap
func useSlice() {
slice := make([]int, 1000)
// Use slice...
}However, the flexibility of slices almost always outweighs the marginal performance difference. Optimize only if profiling shows it's a bottleneck.
Capacity Management
// Efficient: pre-allocated capacity
slice := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
slice = append(slice, i) // No reallocation
}
// Less efficient: grows dynamically
var slice []int
for i := 0; i < 1000; i++ {
slice = append(slice, i) // Multiple reallocations
}Common Pitfalls
1. Appending to Shared Slices
original := []int{1, 2, 3}
slice1 := original
slice2 := original
slice1 = append(slice1, 4)
slice2 = append(slice2, 5)
fmt.Println(original) // Might be [1 2 3] or [1 2 3 4] depending on capacity
fmt.Println(slice1) // [1 2 3 4]
fmt.Println(slice2) // [1 2 3 5]Solution: Always reassign the result of append.
2. Loop Variable Addresses
// Wrong
items := []int{1, 2, 3}
var pointers []*int
for _, item := range items {
pointers = append(pointers, &item) // ❌ All point to same variable!
}
// All pointers point to the same address (last value)
for _, p := range pointers {
fmt.Println(*p) // 3, 3, 3
}
// Correct
for i := range items {
pointers = append(pointers, &items[i]) // ✅ Point to slice elements
}Conclusion
In Go, arrays should almost never be used for general-purpose programming. Here's why:
- Size is part of the type: Makes generic code impossible
- Pass by value: Inefficient and confusing
- No growth: Cannot append or resize
- Poor ecosystem integration: Standard library uses slices
Use slices instead because they offer:
- ✅ Flexibility with dynamic sizing
- ✅ Efficient pass-by-reference semantics
- ✅ Built-in growth with
append - ✅ Full standard library support
- ✅ Cleaner, more maintainable code
Remember: The only time to use arrays is when you have a legitimate reason—fixed-size data structures with meaningful size constraints, specific performance requirements, or system-level programming needs. When in doubt, always use slices.
Related Posts
Go Learning Roadmap:
- Go Learning Roadmap - Complete series overview
- Phase 1: Go Fundamentals - Variables, types, control flow, functions
- Go Goroutines and Concurrency - Concurrent programming
- Go Channels and Communication - Channel patterns
Other Go Posts:
- Pointer Receiver vs Value Receiver - Choosing the right receiver type
- Composition Over Inheritance - Go's approach to code reuse
Further Reading
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.