Lesson 1 of 5
Slices: The Three-Word Header
"A slice is a descriptor of an array segment. It consists of a pointer to the array, the length of the segment, and its capacity." — Go Blog, "Go Slices: usage and internals"
Part I established that a slice is "a value containing a pointer", which was enough to explain why copying one shares data. It is not enough to explain append, and append is where slices get their reputation.
Three fields. That is the whole data structure: a pointer to an array, a length, and a capacity. Every behaviour that has ever surprised you about slices falls out of those three fields and one rule about what append does when the capacity runs out — and once you can predict that rule, the surprises stop.
Consider the two facts that seem to contradict each other. Pass a slice to a function, write to s[0], and the caller sees it. Pass a slice to a function, append to it, and the caller usually does not. Both follow from the header. Writing to s[0] goes through the pointer, which the copy shares. append may need a bigger array, and when it allocates one it returns a new header pointing somewhere else — which the caller never receives, because the caller's header was copied on the way in.
"Usually does not" is the part that makes this dangerous rather than merely confusing. Whether the caller sees an appended element depends on whether there was spare capacity, which depends on how the slice was built, which is not visible at the call site. Code that works with one input silently breaks with another.
So this chapter takes the header apart: what the three fields do, exactly when append reallocates, how slicing shares an array and how to stop it, what actually copies, and the memory leak that a three-field header makes possible. It is the most mechanical chapter in the volume and the one that pays off most often.
Why does writing to s[0] inside a function reach the caller, while append usually does not — and what does "usually" depend on?
Three fields
Start by making the header visible, because len and cap are the only windows onto it.
Click Run to execute the code.
The distinction at the end matters more than it looks. A nil slice and an empty slice have the same length, the same capacity, and behave identically for len, range, and append — but they are different values, and == nil can tell them apart. Part I noted that encoding/json renders one as null and the other as []. This is where that difference lives: in the pointer field.
The c[3] panic is the clearest demonstration that len and cap are genuinely separate. Capacity is not a permission to read; it is a promise about how much room exists before append needs new memory. Indexing is bounded by length.
Think of a hotel corridor and a booking. The corridor is the array — a fixed run of rooms that exists whether or not anyone is in them. Your booking is the slice header: it says which room you start at, how many rooms you currently occupy, and how many rooms are available to you before the corridor runs out. Occupancy is length; available run is capacity. Two bookings can overlap on the same corridor, and if one party redecorates room 4 the other party sees it, because the corridor is shared and the bookings were only ever descriptions of a stretch of it. Now the important part: when your party grows beyond the available run, the hotel does not extend the corridor. It moves you to a longer corridor entirely and hands you a new booking slip — and anyone still holding the old slip is describing the old corridor, with the old guests, unaware that you left. That is append reallocating, and the stale slip is why a function that appends must return the new slice.
When append reallocates
Here is the rule, and it is short: if there is spare capacity, append writes into the existing array and returns a header with a longer length. If there is not, it allocates a bigger array, copies everything across, and returns a header pointing at the new one.
Click Run to execute the code.
The addBroken demonstration is worth reading twice, because the failure is worse than "the caller does not see it". With spare capacity, the append genuinely writes a 99 into the caller's array at index 1 — a slot the caller's header does not cover. The caller sees nothing now. But if the caller later appends its own element, it overwrites that 99, or worse, another slice over the same array with a longer length can see it.
That is the whole argument for the convention: a function that grows a slice must return it. Not because returning is tidier, but because the alternative is correct only when capacity happens to be exhausted, and silently corrupting when it is not.
Go's growth factor has changed, and the way it changed is a good illustration of why the documentation says "implementation-defined" rather than giving you a number. For years the rule was roughly: double the capacity while the slice is small, then grow by about 25% once it exceeds 1024 elements. In Go 1.18 that threshold moved to 256 and the transition was smoothed, because the old rule produced a sharp discontinuity in allocation behaviour right where a lot of real workloads sat. Nothing in any program's semantics changed — which is the point. Because the only guarantee is "capacity may increase by an unspecified amount", the runtime was free to retune it, and code that relied only on the guarantee kept working. Code that had measured the old factor and hard-coded assumptions about it did not. This is a recurring shape in Go's design: the language specifies the contract narrowly and the implementation keeps the freedom to change everything else. It is the same reason map iteration order is deliberately randomised — not because ordering is impossible, but because guaranteeing it would foreclose future implementations, and permitting programs to depend on it accidentally is worse than making the dependency impossible.
Slicing shares, and how to stop it
A slice expression makes a new header over the same array. That is cheap and usually what you want, and occasionally it is a bug with teeth.
Click Run to execute the code.
The three-index slice expression is the least-known genuinely important piece of slice syntax. s[low:high:max] sets the capacity explicitly, and s[a:b:b] produces a slice with zero spare capacity — so any append to it must allocate, and therefore cannot reach into the original array.
The use case to remember is returning a subslice of internal state. If a method returns s.buf[0:n], the caller receives a slice whose capacity extends across the rest of your buffer, and a single append on their side writes into your data. Returning s.buf[0:n:n] costs one character and makes that impossible.
The habit that has saved me most is reading every append whose result is not assigned back to the same variable, because that is the shape of nearly every slice bug I have written. s = append(s, x) is fine. append(s, x) with the result thrown away is dead code the compiler permits. And t := append(s, x) is the one to stare at, because whether t and s now share an array depends entirely on cap(s) — which means the same line behaves differently depending on how s was constructed elsewhere. If I want independence I say so explicitly with a copy. The second habit is a small piece of API hygiene: any exported method returning a subslice of internal state gets the three-index form, buf[0:n:n], because the alternative hands out write access to whatever follows and nothing in the returned value hints at it. The third is to reach for make([]T, 0, n) whenever I know the eventual size — not for the allocation saving, which is usually small, but because it documents the expected magnitude at the place the slice is created, and a reader learns more from that than from a comment.
What actually copies
Three idioms produce an independent slice, and one thing that looks like it does, does not.
Click Run to execute the code.
The make([]T, 0, n) followed by copy mistake is worth committing to memory because it produces an empty slice with no error and no panic. copy is bounded by the length of the destination, and a destination of length 0 copies nothing regardless of its capacity. The fix is make([]T, len(src)) — length, not capacity.
The delete idiom in the last block is the standard one, and the comment on it is the important part: it shifts the tail left inside the existing array, so the final element remains in memory beyond the new length. For a slice of ints that is harmless. For a slice of pointers it is a leak, which is the next section.
The leak a three-field header allows
Because a slice keeps a pointer into an array, a small slice can keep a large array alive. The garbage collector frees arrays, not ranges.
Click Run to execute the code.
This is the subtlest slice problem and the one most likely to appear as "our memory usage grows and we cannot see why". A slice of length 2 over an array of ten thousand keeps all ten thousand alive, along with everything they point at. Nothing in len, in printing, or in reading the code makes that visible — the only tell is cap.
The pointer-zeroing version of the delete idiom is the one to remember for slices of pointers. append(s[:i], s[i+1:]...) shifts the tail left and leaves a duplicate of the last pointer in the slot beyond the new length, keeping that object alive indefinitely. Setting the vacated slot to nil before shrinking costs one line.
A team ran a service that read JSON payloads from a queue, extracted a small identifier from each, and kept those identifiers in a cache for deduplication. Memory grew steadily and restarted the pods every few hours, and profiling pointed at the JSON decoder, which nobody believed because the payloads were transient. The actual mechanism was that they were slicing the identifier out of the decoded buffer — something close to id := buf[8:24] — and putting that slice in the cache. Each 16-byte identifier held a pointer into a payload that averaged a few hundred kilobytes, so a cache of fifty thousand identifiers was pinning several gigabytes of payloads whose len summed to under a megabyte. The fix was one line: copy the identifier instead of slicing it. What made it hard to find was that every tool told the truth and none of it helped — the cache's own accounting said a megabyte, because it measured lengths, and the heap profile blamed the decoder, because that is where the memory was allocated. The only place the relationship was visible was cap, which nothing was logging. I now treat "a small slice derived from a large one, stored long-term" as a shape to recognise on sight, rather than something to discover from a graph.
Common Mistakes
A function that appends without returning the slice. Correct only when capacity happened to be exhausted, and silently writing into the caller's array when it was not.
Assuming
t := append(s, x)gives an independent slice. Whethertandsshare an array depends oncap(s), which is decided elsewhere.Appending to a subslice.
arr[2:5]has capacity to the end of the array, soappendoverwritesarr[5].Returning a subslice of internal state without the third index.
buf[0:n]hands the caller capacity across the rest of your buffer;buf[0:n:n]does not.make([]T, 0, n)followed bycopy.copyis bounded by destination length, so this copies nothing. Usemake([]T, len(src)).Confusing
capwith permission to index. Reading pastlenpanics however much capacity exists.Keeping a small slice of a large array. The array cannot be freed while any slice points into it; copy if you are keeping only a part.
Using
append(s[:i], s[i+1:]...)on a slice of pointers. The vacated slot beyond the new length still holds a pointer; nil it first.Treating nil and empty slices as identical. They behave the same for
len,rangeandappend, differ under== nil, and marshal tonullversus[].Relying on the growth factor. It is implementation-defined and has changed; rely only on "append may reallocate".
Interview Corner
append has before it must allocate a new array. A slice is really three fields: a pointer to an array, a length and a capacity, and len and cap are how you read the last two.append may return a header pointing at a different array. If there is spare capacity it writes in place and returns a longer length; if not it allocates, copies, and returns a header pointing somewhere new. The parameter was a copy of the caller's header, so the caller never sees either the new length or the new pointer. The worse half is the spare-capacity case: the element really does get written into the caller's array, just past the end of what the caller's length covers — so it is invisible now and can be overwritten or exposed later by another slice over the same array. That means the no-return version is correct only by accident of capacity, which is decided wherever the slice was built. Returning the slice is the only version that is right for all inputs, which is why every append-like function in the standard library does.len and reports a megabyte, and the heap profile blames the JSON decoder because that is where the allocation happened, not what is retaining it. The only place the relationship shows is cap. The fix is to copy rather than slice — append([]byte(nil), buf[8:24]...) or make-plus-copy. More generally I treat "a small slice derived from a large one, stored long-term" as a shape to spot in review, because by the time it is a memory graph the causal chain is invisible. The same mechanism is why deleting from a slice of pointers should nil the vacated slot: the pointer past the new length still keeps its object reachable.Two functions that are the practical payoff of the header. The first is order-preserving delete done properly — shifting the tail left, releasing the vacated slot so a pointer cannot keep an object alive, and handling out-of-range indices without panicking. The second splits a slice into chunks that do not alias the original, which is the trap the naive implementation falls into: s[i:i+size] is cheap and shares storage, so a caller appending to one chunk corrupts the next.
Click Run to execute the code.
Summary
A slice is three fields: a pointer to an array, a length and a capacity. Every surprise follows from those.
lenbounds indexing;capboundsappendbefore reallocation. Reading pastlenpanics however much capacity exists.appendwrites in place when there is spare capacity and allocates a new array when there is not, returning a header pointing at whichever.So a function that appends must return the slice — the no-return version is correct only by accident of capacity, and writes into the caller's array otherwise.
A slice expression shares the array, and capacity extends to the end of it, so appending to
arr[2:5]overwritesarr[5].s[low:high:max]sets capacity explicitly, ands[a:b:b]gives no spare room — the right form for returning a subslice of internal state.copyis bounded by destination length, somake([]T, 0, n)thencopycopies nothing.make+copy,append([]T(nil), s...)andappend([]T{}, s...)all produce independent slices; assignment and slicing do not.A small slice keeps its whole array alive — a leak only
capreveals — and deleting from a slice of pointers should nil the vacated slot, or the removed object stays reachable past the new length.The growth factor is implementation-defined and has changed between releases.
Slices are a window onto an array. The next structure has no array to point at, no addressable elements, and an iteration order that is deliberately randomised so you cannot depend on it — and its zero value, as Part I established, is readable but not writable. The next chapter is Maps and Their Rules.
Slices are the place where Go's usual bargain runs the other way, and it is worth noticing because the rest of the language is so consistent about it. Everywhere else, Part I found Go trading expressive power for what a reader can conclude locally — no operator overloading so + always means +, no inheritance so a method's origin is visible, no exceptions so failure appears in the signature. The slice header breaks that pattern. Whether t := append(s, x) aliases s is decided by a capacity you cannot see from that line, set somewhere else entirely, and the language offers no annotation for it. That is precisely the kind of non-local reasoning Go elsewhere goes out of its way to eliminate. I do not think it was avoidable — the alternative designs are a slower language or a much heavier type system, and Rust's answer shows how much machinery it takes to make aliasing visible in types. But it does explain why slices produce a disproportionate share of Go's real bugs relative to how simple they are. The mechanism is three fields and takes twenty minutes to learn; the discipline is remembering, at every call site forever, that a number you cannot see is deciding whether two variables are the same data. Simple to understand and easy to get wrong is an unusual combination, and it is a good argument for knowing this chapter cold rather than looking it up.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.