🔖 Analogy
A backing array is a fixed bookshelf with a set number of slots. A slice is a bookmark that says “start at slot 1, I’m looking at 3 books, and there’s room for 5 before the shelf ends.” Move or widen the bookmark and you’re still pointing at the same shelf — which is exactly why two bookmarks over one shelf can surprise you.
Array vs slice
An array has a fixed size baked into its type — [3]int and [4]int are different types — so you rarely use them directly. A slice ([]int) is a flexible, growable view over an array Go manages for you. It’s the workhorse you’ll reach for 99% of the time.
var arr [3]int // fixed: exactly 3 ints, size is part of the type
s := []int{10, 20, 30} // slice: grows with append
s = append(s, 40)
The slice header
A slice value is a tiny three-field struct. It does not contain the elements — it points at them:
graph LR subgraph SH["slice header (s := arr[1:4])"] direction TB P["ptr →"] L["len = 3"] C["cap = 4"] end P --> B1 subgraph BACK["backing array (length 5)"] direction LR B0["[0]"] B1["[1]"] B2["[2]"] B3["[3]"] B4["[4] spare"] end
len(s) is how many elements you can see; cap(s) is how many exist from the pointer to the end of the backing array. Copying a slice (passing it to a function, assigning it) copies just those three fields — both copies share the same elements.
append and growth
append writes into spare capacity when there is some; otherwise it allocates a bigger backing array, copies, and returns a slice pointing at the new one. Watch the capacity jump:
package main
import "fmt"
func main() {
s := []int{}
prevCap := -1
for i := 0; i < 17; i++ {
s = append(s, i)
if cap(s) != prevCap {
fmt.Printf("len=%2d cap=%2d <- reallocated & copied\n", len(s), cap(s))
prevCap = cap(s)
}
}
fmt.Println("preallocate to skip the copies: make([]int, 0, 17)")
}
If you know the final size, make([]int, 0, n) reserves capacity up front and avoids every reallocation.
The aliasing trap
Because re-slicing shares the backing array, a write through one slice is visible through another — and append can write through too, if there’s spare capacity:
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4, 5}
view := base[1:3] // len 2, cap 4 — shares base's array
view[0] = 99
fmt.Println("base:", base) // [1 99 3 4 5] — base changed too!
// append has spare capacity, so it overwrites base[3] in place:
view = append(view, 100)
fmt.Println("base after append:", base) // [1 99 3 100 5]
// To isolate, copy into a fresh slice:
safe := make([]int, len(view))
copy(safe, view)
safe[0] = -1
fmt.Println("base untouched:", base, " safe:", safe)
}
⚠️ Cut capacity with a full-slice expression
To hand out a sub-slice that cannot clobber the parent via append, use the three-index form s[low:high:max], which caps capacity at max-low. For example base[1:3:3] has len 2 and cap 2, so the next append is forced to allocate a fresh array instead of writing into base. Also: re-slicing keeps the whole backing array alive, so slicing one element out of a huge slice can leak memory — copy it out if you need only the small piece.
See also
- Big-O & complexity — why append is amortized O(1) and middle-delete is O(n).
- linked lists — O(1) middle insert/delete, at the cost of O(n) access.
- hash tables — O(1) lookup when index order doesn’t matter.
- slices (fundamentals) — the language-level mechanics in more depth.
Next: when the data isn’t contiguous — linked lists.
Related topics
Measure cost before you optimize — Big-O for time and space, the common growth classes, and how to read the complexity of real Go code.
linearLinked ListsNodes joined by pointers — O(1) insert/delete at a known position, O(n) access; singly vs doubly linked, the reversal classic, and when a slice is the better choice.
linearHash TablesAverage O(1) lookup via hashing — how Go's built-in map works, collisions and load factor, the comma-ok idiom, and the ordering and nil-map gotchas.
Check your understanding
Score: 0 / 51. What are the three fields of a Go slice header?
A slice is a small struct: a pointer to an element in a backing array, len (elements in view) and cap (elements from the pointer to the end of the array). Copying a slice copies the header, not the data.
2. Two slices share a backing array. You write through one of them. What happens?
Re-slicing (b := a[1:3]) does not copy elements — both headers point into the same array, so a write through one is visible through the other. Use copy() or a full-slice expression to isolate.
3. Why is `append` amortized O(1) rather than always O(1)?
Within spare capacity append is O(1). When cap is exceeded Go allocates a bigger array (≈2× for small slices) and copies the old elements; doubling spreads that O(n) cost so the average stays O(1).
4. How do you hand out a sub-slice that CANNOT clobber the parent's array on a later append?
s[low:high] keeps the parent's spare capacity, so an append can overwrite the parent. The full-slice expression s[low:high:max] sets cap to max-low; with cap==len the next append is forced to allocate a new backing array, isolating the parent. (copy() also isolates.)
5. What's the cost of deleting an element from the middle of a slice?
Removing index i (e.g. s = append(s[:i], s[i+1:]...)) copies every element after i one slot left — O(n). Slices are great for append/index but poor for middle insert/delete; a linked list gives O(1) there (once you hold the node).
Comments
Sign in with GitHub to join the discussion.