The idea in one minute#
An array is a fixed-size block of elements, laid out one after another; its length is part of its type and assigning it copies every element. A slice is a three-word header — pointer, length, capacity — describing a window onto an array that lives somewhere else.
Slicing (s[2:5]) makes a new header over the same array. append writes into spare
capacity if there is any, and otherwise allocates a bigger array and copies. Whether two slices
share memory after an append therefore depends on capacity — the source of most slice bugs.
An analogy#
An array is a row of numbered lockers bolted to the floor. A slice is an index card that says “start at locker 12, you may use 3, and there are 8 before the row ends”. Hand a copy of the card to a friend and you both use the same lockers. If you need more than 8, the school builds a new, longer row, moves your things, and gives you a new card — your friend’s old card still points at the old lockers.
A picture#
flowchart TB
subgraph HDR["Slice headers, 24 bytes each"]
S["s<br/>ptr, len 5, cap 8"]
T["t := s[1:3]<br/>ptr+1, len 2, cap 7"]
end
subgraph ARR["One backing array on the heap, cap 8"]
direction LR
E0["0: a"] --- E1["1: b"] --- E2["2: c"] --- E3["3: d"] --- E4["4: e"] --- E5["5: spare"] --- E6["6: spare"] --- E7["7: spare"]
end
S --> E0
T --> E1
T -.->|"append(t, X) writes here:<br/>index 3, inside s"| E3
class S,T queue
class E0,E1,E2,E3,E4 memory
class E5,E6,E7 neutralHow it really works#
Arrays#
[4]int and [5]int are different types. An array is a value: assigning or passing it copies
all of it, and its size is known at compile time, so it can live on the stack or directly
inside a struct with no pointer involved. Arrays are the building block; you will mostly use
them as fixed-size keys, buffers and matrix rows.
The slice header#
// what the runtime sees (reflect.SliceHeader, conceptually)
type slice struct {
ptr *T // first element this slice can see
len int // elements in use: valid indexes are 0..len-1
cap int // elements from ptr to the end of the backing array
}| Expression | Result |
|---|---|
make([]T, n) | New array of n zeroed elements; len = cap = n |
make([]T, 0, n) | New array of n; len 0, cap n — the “I know how many” form |
s[i:j] | Same array; ptr+i, len j-i, cap cap(s)-i |
s[i:j:k] | Same, but cap limited to k-i — the full slice expression |
var s []T | nil slice: ptr nil, len 0, cap 0. Safe to len, range and append |
len(s), cap(s) | Read the header; constant time |
copy(dst, src) | Copies min(len) elements; the way to get independent data |
slices.Clone(s) | A new slice with its own array |
append#
s = append(s, x) // always assign the result- If
len < cap: writexinto the existing array at indexlen, return a header withlen+1. No allocation; anyone sharing the array sees the write. - Otherwise: allocate a larger array, copy the elements, write
x, return a header pointing at the new array. The old array is untouched and, if nothing else refers to it, garbage.
Growth: roughly doubling while the slice is small and about 1.25× once it is large
(the changeover is gradual, around 256 elements), then rounded up to one of the allocator’s
size classes (III.03). Doubling makes append amortized constant time; the price is one
reallocation-and-copy per growth step and up to 2× over-allocation.
If you know the final size, say so: make([]T, 0, n). One allocation instead of a dozen.
The aliasing traps#
| Trap | What happens | Fix |
|---|---|---|
t := s[:2]; t = append(t, x) | Overwrites s[2] | s[:2:2] to cap the capacity, or slices.Clone |
Returning buf[:n] from a reused buffer | The caller’s data changes on the next read | Copy before returning or storing |
Keeping big[:10] alive | The whole big array stays in memory | slices.Clone(big[:10]) |
| Passing a slice to a function that appends | The caller’s header is unchanged: it does not see the new length | Return the slice |
for _, v := range s { v.x = 1 } | v is a copy of the element | Index: s[i].x = 1 |
Slices of slices#
[][]float32 is a slice of headers, each pointing at its own row. Rows can be anywhere in
memory, so walking a matrix this way hops between allocations. Numerical code uses one flat
slice and index arithmetic, data[i*cols+j] — contiguous, cache-friendly, one allocation.
Module VI’s tensor is built that way.
Zero-length and nil#
A nil slice and an empty non-nil slice ([]int{}) behave the same for len, range and
append. They differ only when compared with nil and in some encoders (JSON encodes nil as
null, empty as []). Prefer len(s) == 0 to s == nil.
Code#
// slices.go — headers, sharing, growth and the aliasing trap, measured.
package main
import (
"fmt"
"testing"
"unsafe"
)
func header(name string, s []int) {
fmt.Printf("%-10s ptr=%p len=%d cap=%d %v\n", name, unsafe.SliceData(s), len(s), cap(s), s)
}
func main() {
s := make([]int, 5, 8)
for i := range s {
s[i] = i
}
t := s[1:3]
header("s", s)
header("t=s[1:3]", t)
fmt.Printf("t starts %d bytes after s: same array\n\n",
uintptr(unsafe.Pointer(unsafe.SliceData(t)))-uintptr(unsafe.Pointer(unsafe.SliceData(s))))
// The trap: t has spare capacity that overlaps s.
t = append(t, 99)
header("s after", s)
fmt.Println("append(t, 99) overwrote s[3]")
// The fix: limit capacity so append must allocate.
u := s[1:3:3]
u = append(u, -1)
header("u", u)
header("s still", s)
fmt.Println()
// Growth: watch capacity change as we append.
var g []int
last := -1
fmt.Print("capacities while appending 2,000 ints: ")
for i := 0; i < 2000; i++ {
g = append(g, i)
if cap(g) != last {
fmt.Print(cap(g), " ")
last = cap(g)
}
}
fmt.Println()
// What preallocation saves.
const n = 100000
grow := testing.AllocsPerRun(10, func() {
var x []int
for i := 0; i < n; i++ {
x = append(x, i)
}
})
pre := testing.AllocsPerRun(10, func() {
x := make([]int, 0, n)
for i := 0; i < n; i++ {
x = append(x, i)
}
})
fmt.Printf("\nappending %d ints: %.0f allocations growing, %.0f preallocated\n", n, grow, pre)
// A function gets a copy of the header.
add := func(x []int) { x = append(x, 7); _ = x }
h := make([]int, 0, 4)
add(h)
fmt.Println("after add(h): len(h) =", len(h), "but the array holds", h[:1])
}Remember this#
- A slice is (pointer, length, capacity) — 24 bytes — over an array that lives elsewhere.
- Slicing shares;
copyandslices.Cloneseparate. appendreuses spare capacity or reallocates; always assign its result.- Preallocate with
make([]T, 0, n)when you known. Use a flat slice for matrices.
Try it#
- Run
slices.go. From the printed capacities, where does growth change from doubling to something slower? - Write a
Filter(s []int, keep func(int) bool) []intthat reuses the input’s backing array (out := s[:0]). What does the caller’s original slice look like afterwards? - Build a 1,000 × 1,000 matrix as
[][]float64and as one flat[]float64. Count the allocations for each withtesting.AllocsPerRun.
Check yourself#
- What are the three fields of a slice header?
- When does
appendallocate, and what happens to slices sharing the old array? - What does the third index in
s[i:j:k]do?