Slices Deep Dive
Slices Deep Dive
A slice in Go is a lightweight data structure that wraps an underlying array. Understanding its internals helps you write efficient, bug-free code.
Slice Internals Every slice is a three-field descriptor:
- Pointer: points to the first element of the underlying array
- Length (len): the number of elements the slice currently holds
- Capacity (cap): the number of elements in the underlying array starting from the slice's pointer
When you take a sub-slice s[1:3], the result shares the same underlying array. Mutating the sub-slice mutates the original — a frequent source of bugs.
make([]T, len, cap)
Use make when you know the size in advance. Providing a capacity avoids repeated allocations during append:
s := make([]int, 0, 100) // len=0, cap=100append and Growth
append returns a new slice header. If the backing array has room, no allocation occurs. When capacity is exhausted, Go allocates a new array (typically doubling capacity for small slices) and copies existing elements. Always capture the return value:
s = append(s, newElement)copy(dst, src)
copy copies min(len(dst), len(src)) elements and returns the number copied. It never grows slices automatically, making it predictable for buffer management.
Slice Tricks Several common patterns arise repeatedly:
Delete element at index i (order preserved):
s = append(s[:i], s[i+1:]...)Delete element at index i (order not preserved, faster):
s[i] = s[len(s)-1]
s = s[:len(s)-1]Insert element at index i:
s = append(s[:i+1], s[i:]...)
s[i] = newValueReverse a slice in place:
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}2D Slices A slice of slices models a matrix. Each inner slice can have a different length (a jagged array):
matrix := make([][]int, rows)
for i := range matrix {
matrix[i] = make([]int, cols)
}range Over Slices
range returns both the index and a copy of the value. Modify the original through indexing, not through the range variable:
for i, v := range s {
fmt.Println(i, v)
}nil vs Empty Slice
var s []int— nil slice;s == nilis true; len and cap are 0s := []int{}ormake([]int, 0)— non-nil empty slice;s == nilis false
Both are safe to append to and to range over. Prefer nil slices as zero values; prefer non-nil empty slices when you must distinguish "no data yet" from "explicitly empty" (e.g., JSON serialisation: nil encodes to null, empty encodes to []).
Code Examples
package main
import "fmt"
func main() {
// Literal vs make
literal := []int{1, 2, 3}
made := make([]int, 3, 6) // len=3, cap=6
fmt.Printf("literal: len=%d cap=%d\n", len(literal), cap(literal))
fmt.Printf("made: len=%d cap=%d\n", len(made), cap(made))
// Append and growth — watch capacity jumps
var s []int
for i := 0; i < 9; i++ {
s = append(s, i)
fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
}
// Sub-slice shares backing array
a := []int{10, 20, 30, 40, 50}
b := a[1:3] // shares array with a
b[0] = 99
fmt.Println("a after mutating b:", a) // [10 99 30 40 50]
}Capacity doubles when exhausted, amortising allocation cost. Sub-slices share memory, so mutation is visible through the original.
package main
import "fmt"
func deleteAt(s []int, i int) []int {
return append(s[:i], s[i+1:]...)
}
func insertAt(s []int, i, v int) []int {
s = append(s, 0)
copy(s[i+1:], s[i:])
s[i] = v
return s
}
func reverse(s []int) {
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}
}
func main() {
s := []int{1, 2, 3, 4, 5}
// copy into a brand-new backing array
clone := make([]int, len(s))
copy(clone, s)
clone[0] = 99
fmt.Println("original:", s) // unaffected
fmt.Println("clone: ", clone)
// delete index 2
s = deleteAt(s, 2)
fmt.Println("after delete:", s) // [1 2 4 5]
// insert 99 at index 1
s = insertAt(s, 1, 99)
fmt.Println("after insert:", s) // [1 99 2 4 5]
// reverse
reverse(s)
fmt.Println("reversed:", s) // [5 4 2 99 1]
}copy creates an independent slice. The delete trick re-uses the backing array in place. insertAt shifts elements right with copy before writing the new value.
package main
import "fmt"
func newMatrix(rows, cols int) [][]int {
m := make([][]int, rows)
for i := range m {
m[i] = make([]int, cols)
}
return m
}
func printMatrix(m [][]int) {
for _, row := range m {
fmt.Println(row)
}
}
func main() {
m := newMatrix(3, 4)
// Fill with row*10 + col
for r := range m {
for c := range m[r] {
m[r][c] = r*10 + c
}
}
printMatrix(m)
// Jagged slice — each row has a different length
triangle := make([][]int, 4)
for i := range triangle {
triangle[i] = make([]int, i+1)
for j := range triangle[i] {
triangle[i][j] = j + 1
}
}
fmt.Println("--- triangle ---")
printMatrix(triangle)
}A 2D slice is a slice of slices. Each inner slice is allocated independently, so rows can have different lengths — a jagged array.
Quick Quiz
1. What three fields make up a slice descriptor in Go?
2. Which statement correctly explains when append allocates a new backing array?
3. What is the key difference between a nil slice and an empty slice in Go?
4. Why must you always capture the return value of append?
Was this lesson helpful?