Three Words That Represent a Sequence
A handler receives []byte request bodies all day, and none of them arrive with the data attached. What the handler actually receives is a header — three machine words that describe where the data lives, how much is visible, and how much room follows:
s ┌───────────┬─────┬─────┐
│ ptr │ len │ cap │ 24 bytes on 64-bit
└─────┬─────┴─────┴─────┘
│
v
┌────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │ 50 │ backing array
└────┬────┴────┴────┴────┘
└──── len=3 ────────┘ cap=5 (from ptr to end)
ptr addresses the first element this slice can reach, len is how many elements are currently visible through it, and cap is how many elements exist in the backing array from that pointer onward. Length answers "what can I index?" — indices 0..len-1 are legal. Capacity answers "how far could this window grow before it must reallocate?" — anything below cap can be addressed by reslicing without touching the allocator.
func main() {
var scores []int // nil slice: ptr nil, len 0, cap 0
scores = []int{10, 20, 30} // literal over fresh storage, len 3, cap 3
pre := make([]int, 0, 100) // empty window over 100 slots, len 0, cap 100
fmt.Println(len(scores), cap(scores)) // 3 3
fmt.Println(len(pre), cap(pre)) // 0 100
fmt.Println(scores == nil, pre == nil) // false false
}
The nil slice deserves attention because it is the zero value and it is fully operational: len and cap return 0, range visits nothing, and append starts fresh storage for it. Your functions can return a nil slice as "no results" without a special case at the call site (Nil Checks and Zero-Value Design).
The header explains the economics of slices. Passing a []int with a million elements into a function copies 24 bytes, not 8 MB. Assigning one slice to another duplicates the header only. That is why slice-heavy Go code moves data around cheaply — and also why two variables can point at the same elements, a property that produces some of the language's most common bugs, which you will meet directly in append, copy, and Slice Aliasing.
Four Ways to Build a Slice, Each with a Different Shape
The creation form you pick decides the length, the capacity, and who owns the storage. There are four, and each earns its place.
Literal — the element list defines both length and capacity, over storage allocated for exactly that list:
names := []string{"ada", "lin", "grace"} // len 3, cap 3
empty := []string{} // len 0, cap 0, but NOT nil
make — you state the shape in advance. The two-argument form gives matching length and capacity; the three-argument form reserves capacity you intend to fill later:
buf := make([]byte, 4096) // len 4096, cap 4096 — a full read buffer
log := make([]string, 0, 128) // len 0, cap 128 — grows without reallocation
make([]string, 0, 128) matters in loops: every append to it fits inside existing capacity until the 129th element, instead of reallocating as capacity chases length.
Slicing an array or another slice — a second header over storage that already exists, with no copy:
arr := [6]int{2, 4, 6, 8, 10, 12}
mid := arr[1:4] // [4 6 8], len 3, cap 5 — from index 1 to end of arr
tail := arr[3:] // [8 10 12], len 3, cap 3
whole := arr[:] // all six, len 6, cap 6
Read arr[1:4] as "start at element 1, stop before element 4." Capacity of the result runs to the end of the underlying array, so mid has cap 5: it can reslice up to arr[5] without allocating.
Bounded slicing (the full slice expression) — a third bound hides the rest of the storage from capacity:
bounded := arr[1:4:4] // len 3, cap 3 — elements 4..5 invisible
The max bound means any future append to bounded must allocate a fresh array instead of overwriting arr[4]. That is the cheapest defense against accidental aliasing, and the details live in append, copy, and Slice Aliasing.
Bounds are checked at runtime: arr[1:7] panics because it exceeds the array, and arr[:8] on a slice of cap 6 panics too. Reslicing may reach into capacity but never past it — capacity is a hard wall, not a hint.
Walking the Slice — and Noticing Who Shares the Storage
Iteration and indexing on slices look like arrays, but the header underneath changes what mutation means. Start with the ordinary forms:
ids := []int{101, 102, 103}
for i, id := range ids { // index + element copy
fmt.Println(i, id)
}
for i := range ids { // indices only, when values are unneeded
ids[i] *= 10
}
fmt.Println(ids) // [1010 1020 1030]
The index form works here exactly as it does for arrays, because both address the same backing storage. Now create a second slice that reads from the first:
a := []int{1, 2, 3, 4, 5}
b := a[:2] // new header, same backing array
b[0] = 99
fmt.Println(a) // [99 2 3 4 5] — a sees the write
fmt.Println(b) // [99 2]
b copied no elements; it copied a header. Element 0 is one memory location reachable through both headers, so writing through either one is visible through both. Two consequences follow:
- Element writes propagate, header changes do not. If a function does
s = s[1:]on its parameter, the caller's slice is unchanged — reslicing reassigns a local header. But if it writess[0], the caller sees it. This asymmetry trips up almost everyone once (Pointers as Function Parameters). - Appending can cross the boundary. If
bgrows past its capacity,appendallocates a new array andbsilently detaches froma. Until then, they are two views of one block.
When a function must not let the caller observe its edits — or vice versa — copy the elements first:
func sanitize(in []string) []string {
out := append([]string(nil), in...) // fresh storage, len preserved
for i, s := range out {
out[i] = strings.ToLower(s)
}
return out
}
append([]string(nil), in...) is the idiomatic clone: build a new slice from nil, spread the originals into it, then edit freely. Knowing when storage is shared and when it is duplicated is the core skill of slice-heavy code.
nil, Empty, and the Contract You Hand Callers
Two slices both report len(s) == 0, both loop zero times, and both accept append. They still differ in two places that show up in real systems, so pick one habit and keep it.
var a []string // nil
b := []string{} // empty, non-nil
fmt.Println(len(a), len(b)) // 0 0
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
ja, _ := json.Marshal(a) // "null"
jb, _ := json.Marshal(b) // "[]"
JSON is where the difference becomes user-visible: a nil slice serializes as null, an empty one as []. Clients parsing "items": null instead of "items": [] is a classic API complaint. Internally, treat them identically — range, len, cap, and append all work on nil — and standardize on one form when returning. Returning nil for "no results" is common, provided your JSON layer converts or your contract documents it (JSON Handling).
Reading through a slice you do not own deserves one more rule: decide whether the caller may keep what you returned. If a function returns a slice backed by internal state, the caller can read — and anyone who appends past capacity or writes elements can corrupt that state by accident. The defensive version clones before handing over:
func (c *Config) Labels() []string {
out := make([]string, len(c.labels))
copy(out, c.labels)
return out
}
For read-mostly internal code, skip the clone and document that the slice must not be modified — copying a large slice on every access has its own cost. The choice is about who owns the storage after the call returns, and stating it explicitly is what separates a slice-literate API from a bug report three months later.
With headers, sharing, and bounds now concrete, the stage is set for growth: what append does when capacity runs out, how copy moves elements between slices, and every aliasing trap that follows from two headers over one array.
Quiz
1. s := make([]int, 3, 8) — what do len(s) and cap(s) report?
2. a := []int{1,2,3}; b := a[:2]; b[0] = 99. What is a[0]?
3. A function receives s []int and executes s = s[1:] before appending one element. What does the caller observe?
4. s := make([]int, 2, 4) and you evaluate s[:6]. What happens, and why?
Flashcards
Question
What are the three fields of a slice header, and which one answers 'can I append without allocating'?
Click to reveal answer
Answer
Pointer to the first reachable element, length (visible elements), and capacity (elements from the pointer to the end of backing storage). Capacity answers the append question: while len < cap, append writes in place; at len == cap it must allocate a new array.
Question
Why does passing a million-element slice to a function stay cheap, while passing a million-element array does not?
Click to reveal answer
Answer
A slice parameter copies only the 24-byte header, leaving elements in place; an array parameter copies every element. That header copy is also why two slices can reference the same storage — cheap sharing and aliasing risk are the same feature.
Question
What distinguishes arr[1:4] from arr[1:4:4] when the array has six elements?
Click to reveal answer
Answer
arr[1:4] has len 3 and cap 5, so it can later reslice up to index 5 of the original array. arr[1:4:4] has len 3 and cap 3: the full slice expression hides elements 4 and 5, forcing any append to allocate fresh storage instead of writing into the shared array.
Question
Where does nil vs empty slice actually show up if len, range, and append treat them the same?
Click to reveal answer
Answer
Mainly in serialization and identity checks: json.Marshal turns a nil slice into null and an empty slice into [], and s == nil only holds for the nil form. Functions that document 'nil means no results' depend on callers not conflating the two.
Question
Your function returns a slice built from an internal buffer, and a caller later corrupts the buffer without touching the function again. What went wrong?
Click to reveal answer
Answer
The returned header still pointed at internal storage, so the caller shared the backing array — element writes (or an append that stayed within capacity) mutated your internals. Either clone before returning (copy into fresh storage) or document that the result must be treated as read-only.
Revision Notes
Key Takeaways
- 1. A slice is a 24-byte header — pointer, length, capacity — describing a window over backing storage
- 2. Slicing and assignment copy headers only, so shared storage (and aliasing bugs) come free with the type
- 3. Capacity is a hard bound: reslicing may use it but never exceed it, and append allocates the moment len meets cap
- 4. The full slice expression s[low:high:max] is the one-line defense against an appended child writing into a parent's storage
- 5. nil and empty slices behave identically except for == nil and JSON output (null vs []), so pick one convention per API
Interview Tips
- • "Draw a slice" — sketch ptr/len/cap over a backing array, then say 'passing it copies 24 bytes, not the data'
- • Explain the difference between s = s[1:] and s[0] = 9 for a caller: header reassignment is local, element writes are shared
- • When asked how to prevent aliasing across a sub-slice, show the three-index form: data[i:j:j]
- • "nil slice or empty slice?" — say both are usable, then name the one place it matters: json null vs []
Cheat Sheet
Slice Basics and the Slice Header
Header anatomy (24 bytes on 64-bit):
| Field | Answers |
|---|---|
| ptr | where the visible window starts |
| len | what I can index right now (0..len-1) |
| cap | how far the window can grow before reallocating |
Creation forms:
| Form | len / cap | Notes |
|---|---|---|
[]int{1,2,3} |
3 / 3 | fresh storage from the literal |
[]string{} |
0 / 0 | empty but NOT nil |
var s []int |
0 / 0 | nil — fully usable for read/range/append |
make([]T, n) |
n / n | zeroed elements |
make([]T, n, c) |
n / c | pre-reserve capacity for appends |
arr[1:4] |
3 / to end | view over existing storage, no copy |
arr[1:4:4] |
3 / 3 | max bound seals off the rest (anti-aliasing) |
Code template:
func windows(data []int, size int) [][]int {
var out [][]int
for i := 0; i+size <= len(data); i++ {
out = append(out, data[i:i+size:i+size]) // sealed: appends can't write back
}
return out
}
Rules of thumb: header changes (assign, reslice) stay local; element writes are visible to every sharer; reslicing past cap panics; return nil or [] consistently so your JSON output does too.
Continue Learning
Before You Start
- No prerequisites listed.