Go · lesson 9 of 25
Arrays and Slices in Go
Go arrays and slices run for real: value vs view semantics, len and cap growth, append aliasing bugs, copy, deleting, 2D slices, and the slices package.
By SkillByExample · Updated
Arrays have a fixed size and copy like any value. Slices — what Go code uses almost everywhere — are views onto an array, and most slice surprises come from forgetting that two slices can share one array. Each program below shows one of those surprises.
Arrays are values
The length is part of an array’s type, and assigning an array copies all its elements.
package main
import "fmt"
func main() {
a := [3]int{1, 2, 3}
b := a // full copy
b[0] = 99
fmt.Println(a, b, a == [3]int{1, 2, 3})
days := [...]string{"Mon", "Tue", "Wed"} // length counted for you
fmt.Println(len(days), days)
fmt.Printf("%T\n", days)
}
$ go run arrays.go
[1 2 3] [99 2 3] true
3 [Mon Tue Wed]
[3]string
Changing b left a untouched. Arrays of comparable elements can be compared with ==, and [...] lets the compiler count the elements. Because the size is in the type, a function taking [3]string can’t accept a [4]string — which is why you’ll rarely pass arrays around.
Slices share their backing array
A slice is a header with a pointer, a length and a capacity. Slicing or assigning copies the header, not the elements.
package main
import "fmt"
func main() {
arr := [5]int{10, 20, 30, 40, 50}
s := arr[1:4] // elements 1, 2, 3
fmt.Println(s, len(s), cap(s))
s[0] = 999
fmt.Println(arr)
t := s
t[1] = -1
fmt.Println(s, arr)
}
$ go run share.go
[20 30 40] 3 4
[10 999 30 40 50]
[999 -1 40] [10 999 -1 40 50]
arr[1:4] has length 3 (index 4 excluded) but capacity 4: it can see through to the end of arr. Writing through s changed arr, and t := s gave a second name for the same elements. This sharing is what makes slicing free — no copying — and it’s also the source of every bug in this lesson.
make, len and cap
make([]T, len, cap) allocates a backing array. append grows the slice, allocating a bigger array when capacity runs out.
package main
import "fmt"
func main() {
var s []int
prevCap := -1
for i := range 2000 {
s = append(s, i)
if cap(s) != prevCap {
fmt.Printf("len=%-4d cap=%d\n", len(s), cap(s))
prevCap = cap(s)
}
}
pre := make([]int, 0, 2000)
fmt.Println(len(pre), cap(pre))
}
$ go run growth.go
len=1 cap=1
len=2 cap=2
len=3 cap=4
len=5 cap=8
len=9 cap=16
len=17 cap=32
len=33 cap=64
len=65 cap=128
len=129 cap=256
len=257 cap=512
len=513 cap=848
len=849 cap=1280
len=1281 cap=1792
len=1793 cap=2560
0 2000
Capacity doubles up to 512; past 256 elements the growth factor eases from 2× toward 1.25×, and the result is rounded up to the allocator’s size classes — hence 512 → 848 rather than 1024. These exact numbers are a runtime detail that has changed between Go releases; the shape is what to remember. Every growth copies all existing elements to the new array. If you know the final size, make([]int, 0, n) allocates once.
The append aliasing trap
When there is spare capacity, append writes into the existing array — which another slice may be using.
package main
import "fmt"
func main() {
base := make([]int, 3, 10)
copy(base, []int{1, 2, 3})
a := append(base, 4)
b := append(base, 5)
fmt.Println(a, b)
c := append(base[:3:3], 6) // cap limited to 3: append must copy
fmt.Println(base, c)
}
$ go run alias.go
[1 2 3 5] [1 2 3 5]
[1 2 3] [1 2 3 6]
a should end in 4, but it prints 5. base had room for 10, so both append calls wrote element 3 of the same array; b’s write overwrote a’s. The full slice expression base[:3:3] sets capacity equal to length, forcing append to allocate a fresh array and leaving base alone. Functions that append to a slice they were given, and callers that keep using the original, hit exactly this.
copy and Clone
copy(dst, src) copies min(len(dst), len(src)) elements and returns how many. slices.Clone makes an independent copy in one call.
package main
import (
"fmt"
"slices"
)
func main() {
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
n := copy(dst, src)
fmt.Println(n, dst)
var empty []int
fmt.Println(copy(empty, src), empty)
clone := slices.Clone(src)
clone[0] = 100
fmt.Println(src[0], clone[0])
}
$ go run copy.go
3 [1 2 3]
0 []
1 100
copy never grows the destination: copying into a nil slice copies nothing. Size the destination with make first, or use slices.Clone.
Deleting and inserting
slices.Delete and slices.Insert (Go 1.21+) shift elements in place.
package main
import (
"fmt"
"slices"
)
func main() {
s := []string{"a", "b", "c", "d", "e"}
orig := s
s = slices.Delete(s, 1, 3) // remove indexes 1 and 2
fmt.Println(s, len(s))
fmt.Printf("%q\n", orig)
s = slices.Insert(s, 1, "X")
fmt.Println(s)
}
$ go run delete.go
[a d e] 3
["a" "d" "e" "" ""]
[a X d e]
Delete moved d and e down over b and c in the original array, then zeroed the vacated tail — so orig, which still has length 5, now shows "" "" at the end. Deleting from a slice that other code also holds changes what that code sees.
2D slices
A slice of slices: allocate the outer slice, then each row.
package main
import "fmt"
func main() {
rows, cols := 3, 4
grid := make([][]int, rows)
for i := range grid {
grid[i] = make([]int, cols)
}
grid[1][2] = 7
for _, row := range grid {
fmt.Println(row)
}
jagged := [][]int{{1}, {2, 3}, {4, 5, 6}}
fmt.Println(len(jagged[0]), len(jagged[2]))
}
$ go run grid.go
[0 0 0 0]
[0 0 7 0]
[0 0 0 0]
1 3
Each row is an independent slice, so rows can have different lengths (jagged). Forgetting the inner make leaves every row nil, and grid[1][2] = 7 would panic with index out of range.
Sorting and searching
The slices package sorts, searches and compares slices of any ordered type.
package main
import (
"cmp"
"fmt"
"slices"
)
type Person struct {
Name string
Age int
}
func main() {
nums := []int{5, 2, 8, 1, 9}
slices.Sort(nums)
i, found := slices.BinarySearch(nums, 8)
fmt.Println(nums, i, found, slices.Contains(nums, 3), slices.Max(nums))
people := []Person{{"Ann", 30}, {"Bob", 25}, {"Cy", 30}, {"Di", 25}}
slices.SortStableFunc(people, func(a, b Person) int {
return cmp.Compare(a.Age, b.Age)
})
fmt.Println(people)
}
$ go run sorting.go
[1 2 5 8 9] 3 true false 9
[{Bob 25} {Di 25} {Ann 30} {Cy 30}]
BinarySearch requires a sorted slice and returns the index plus whether the value was found. SortStableFunc takes a comparison returning negative, zero or positive (cmp.Compare produces exactly that) and keeps equal elements in their original order — Bob before Di, Ann before Cy.
Practice
1. Write appendSafe(s []int, v int) []int that never modifies the caller’s backing array, and show that alias.go’s a and b stay independent with it.
Solution
package main
import (
"fmt"
"slices"
)
func appendSafe(s []int, v int) []int {
return append(slices.Clip(s), v)
}
func main() {
base := make([]int, 3, 10)
copy(base, []int{1, 2, 3})
a := appendSafe(base, 4)
b := appendSafe(base, 5)
fmt.Println(a, b)
}
$ go run p1.go
[1 2 3 4] [1 2 3 5]
slices.Clip(s) is s[:len(s):len(s)]: with no spare capacity, every append allocates a new array.
2. Remove every even number from []int{1, 2, 3, 4, 5, 6} in place using slices.DeleteFunc.
Solution
package main
import (
"fmt"
"slices"
)
func main() {
s := []int{1, 2, 3, 4, 5, 6}
s = slices.DeleteFunc(s, func(n int) bool { return n%2 == 0 })
fmt.Println(s, len(s), cap(s))
}
$ go run p2.go
[1 3 5] 3 6
The length drops to 3, but the capacity stays 6: no new array was allocated.
3. Write chunk(s []int, size int) [][]int that splits a slice into pieces of at most size elements. Test it with 7 elements and size 3.
Solution
package main
import "fmt"
func chunk(s []int, size int) [][]int {
var out [][]int
for size < len(s) {
out = append(out, s[:size:size])
s = s[size:]
}
return append(out, s)
}
func main() {
fmt.Println(chunk([]int{1, 2, 3, 4, 5, 6, 7}, 3))
}
$ go run p3.go
[[1 2 3] [4 5 6] [7]]
The chunks share the input’s array; the three-index slice s[:size:size] stops a later append to one chunk from overwriting the next. Go 1.23’s slices.Chunk returns the same pieces as an iterator.
4. Sort []string{"banana", "Apple", "cherry", "apple"} case-insensitively, and break ties so that the uppercase spelling comes first.
Solution
package main
import (
"cmp"
"fmt"
"slices"
"strings"
)
func main() {
words := []string{"banana", "Apple", "cherry", "apple"}
slices.SortFunc(words, func(a, b string) int {
return cmp.Or(
cmp.Compare(strings.ToLower(a), strings.ToLower(b)),
cmp.Compare(a, b),
)
})
fmt.Println(words)
}
$ go run p4.go
[Apple apple banana cherry]
cmp.Or returns the first non-zero comparison, so the case-sensitive compare only decides ties — and "A" < "a" in byte order.