{} The Go Reference

Linear · DSA · Beginner

Arrays & Slices

A Go slice is a view over a backing array — how the (ptr, len, cap) header, append, growth, copy and re-slicing actually behave, and the aliasing traps.

Linear Beginner ⏱ 4 min read Complete

🔖 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:

growth.go — editable & runnable
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:

aliasing.go — editable & runnable
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

Next: when the data isn’t contiguous — linked lists.

Check your understanding

Score: 0 / 5

1. 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.