Slices in Go: a window onto somebody else's array, two paths through append, and a growth rule everyone remembers wrong
A slice does not own its data: it is a window onto somebody else's array, and one write through one window is visible through the other. The lesson goes from that model to the mechanism — the two paths through append, the real growth formula, what hides behind "amortized O(1)", and why "×2 up to 1024, then ×1.25" is wrong in both numbers.
Full technical treatment
TL;DR
A slice does not own its data — it is a window onto somebody else's array.
Two windows can look at the same cells, and a write through one is visible in
the other: the run prints how a single write changes three values — both slices
and the array itself. That is a language guarantee, not an implementation
detail. And append either writes into that same array or moves to a new one —
which is why it returns a slice.
Hence the main consequence: append can silently write into somebody else's
cells. The capacity of s[1:3] is not two: by the specification it is
cap(s) − low, running to the end of the array — so append into such a slice
writes where the neighbour's data lives. The same shared storage is what pins
memory: three bytes kept from a 50 MB file keep all fifty. And deletion costs
not by the length of the slice but by the tail that has to be shifted: measured
at 1401 and 667 ns for shifting 9999 and 4999 elements, while at one element and
none the difference already falls below the resolution of the measurement.
Beyond that come the numbers, the versions and their limits. A slice is a
three-word header (a pointer, a length, a capacity); measured, that is
24 bytes, and those are what gets copied when it is passed to a function,
however many elements the array holds, while [5]int is 40 bytes because an
array is a value. The rule "×2 up to 1024, then ×1.25" is wrong in both
numbers: on go1.24 the threshold is a named constant 256 in
nextslicecap, and the factor just above it is 1.66, because the formula
adds a quarter plus 192 — and specific cap values are not guaranteed at
all. "Amortized O(1)" is about the sum, not about each call: measured,
growing to a million copies 4.15·n elements, while a single append at a
reallocation costs O(n). And for _, v := range copies the element — but that
is an argument about semantics, not speed: in the same measurement it came
out faster than the indexed form.
- an array is a run of elements of one type, and how many there are is fixed in advance;
- an element is taken by its number:
s[0]is the first one, and the same number can be written to; appendadds an element at the end, and the result is assigned back to the same variable.
- what a slice is made of: a pointer, a length, a capacity,
cap, the three-index forms[a:b:c]; growslice,nextslicecap, allocator size classes, amortized analysis;slices.Clone, the difference between anilslice and an empty one.
What is actually being asked
A slices interview almost always walks the same ladder, and it helps to know it in advance — you can see where the conversation is heading:
- "What is a slice?" — testing whether you know about the header. "A dynamic array" closes this question and opens the next one.
- "What does
appenddo?" — testing whether you know there are TWO cases and that they differ by whether there is spare capacity. - "Why did the neighbouring slice change?" (with code on paper) — testing the first two together. This is the central question of the topic.
- "What is the complexity of
append?" — testing whether you distinguish an amortized bound from the cost of a single call.
All four are the same question from different angles: do you know that a slice does not own its data. The lesson follows that ladder.
Base: a slice is a window onto somebody else's array
Start with what a slice is even for. An array in Go fixes how many elements it
holds and lives with that number to the end: [6]int is exactly six numbers and
it will never have others. That is inconvenient almost always: data arrives in
whatever quantity it arrives in, and sometimes you need the whole set, sometimes
a piece of it. A slice is the answer to that — a way to look at part of an
array and speak about that part as a whole.
The key word is "look". A slice does not take the elements for itself and does not copy them: it only describes which stretch of somebody else's array is visible through it. Hence the main surprise of the topic: take a piece of a slice, write something into that piece, and the original array changes, and with it every other slice looking at the same cells.
Here is what that looks like. One array, two slices over it — and one write:
arr := [6]int{10, 20, 30, 40, 50, 60}
a := arr[1:4] // [20 30 40]
b := arr[3:6] // [40 50 60]
a[2] = 99A run of bench/goslice/internals.go prints what became of it:
array [10 20 99 40 50 60] ← was [10 20 30 40 50 60]
a [20 30 99]
b [99 50 60]
One write — three changed values, and not one of them is a copy. a[2] and
b[0] are the same cell of the array.
That is the whole model everything else is derived from: a slice does not own its data. It describes which stretch of somebody else's array is visible through it — a window, not a box. The specification says so verbatim:
A slice, once initialized, is always associated with an underlying array that
holds its elements. A slice therefore shares storage with its array and with
other slices of the same array.
Keep that sentence at hand: it alone answers three different interview questions. Since the storage is shared — appending to one slice may write into somebody else's cells. Since the array may change — appending must return a new window. Since a window pins the whole array — a short slice keeps a long one alive.
That is already enough to answer the basic interview question: a slice is not a container holding its own data, it is a window onto somebody else's array. Everything below is about what that window is made of, what happens when the data stops fitting into it, and what all of that costs.
Mechanism 1: slices, arrays and strings are three different things
Go has three "sequences" and they get conflated constantly. The difference shows up in one number — the size of the value itself:
[]int 24 bytes — three words: pointer, length, capacity
string 16 bytes — two words: pointer and length
[5]int 40 bytes — an ARRAY is a value: five numbers of eight
Read that table again: the size of [5]int depends on the element count, the
size of []int does not. That is the whole difference. An array in Go is a
value, and func f(a [5]int) gets a copy of all five numbers. A slice is a
descriptor, and func f(s []int) gets a copy of three words with somebody
else's memory behind them.
Two practical conclusions follow immediately, worth keeping ready:
- "Pass a slice by pointer for speed" is meaningless: you save eight bytes out
of twenty-four. But
*[1000]intinstead of[1000]intdoes make sense — there you would be copying eight kilobytes. - A function can change your data while returning nothing:
s[0] = 1inside is visible outside, because the pointer in the copied header is the same.
Now look at the header itself and at how an expression turns into its three fields. Switch expressions — the figure stays the same, the numbers change:
The thing to take from that picture: the red zone is not "free space". Those
are cells of the original array that somebody else considers theirs. s[1:3]
does not "cut off a piece" — it opens a window with somebody else's data just
past its right edge, and append writes exactly there.
The rule from the specification, worth being able to quote: for s[low:high]
the capacity is cap(a) - low
, and for the three-index form
s[low:high:max] it is max - low
. The second is the only way to cut the
tail off.
Mechanism 2: what append does
append is not a function in the ordinary sense but a built-in operation the
compiler expands into two paths.
The fast path: there is spare capacity. Then nothing interesting happens:
the element goes into cell s[len], the length grows by one, the pointer and
capacity stay as they were. No allocation, no copying. That is why append in a
loop over a pre-allocated slice is nearly free.
The slow path: capacity ran out. growslice in the runtime is called, and
it does four things:
- computes the new capacity — that is
nextslicecap, dissected under "Deeper"; - converts it to bytes and rounds up to an allocator size class;
- allocates a new array;
- copies the
lenelements already laid down and adds the new one.
Hence the answer to the question that usually comes next: why does append
return a slice? Because on the slow path the array is a different one, and the
pointer in the old header leads elsewhere. append cannot change the caller's
header — that was passed by value. Returning the new pointer and length is the
only way to report them.
And hence the central question of the topic: on the fast path append writes
into an array that already exists, and that array may be shared. Watch what
that looks like step by step — and how the same operation behaves once capacity
runs out:
The second scenario in that picture matters more than the first. Without it you
walk away with the rule "append is dangerous", which is wrong: what is
dangerous is precisely the combination "length below capacity" — which is
precisely what s[1:3] creates.
Mechanism 3: deletion, insertion and the price of shifting
The canonical deletion idiom looks identical for any index:
s = append(s[:i], s[i+1:]...)Before looking at the price it is worth understanding what actually happens here — otherwise one identical expression with wildly different costs looks arbitrary. Arrays have no holes: removing an element from the middle means shifting the entire tail one position left.
before: [A B C D E F] delete C (i = 2)
s[:2] = [A B] s[3:] = [D E F]
step: append writes D, E, F into the cells after B —
that is, into the cells that held C, D, E of the same array
after: [A B D E F | F] length 5, the sixth cell is not cleared
Three things are visible at once, and all three get asked about. First, append
here writes into the same array — that is the fast path from Mechanism 2, the
capacity is trivially sufficient. Second, the work is exactly the size of the
tail. Third, the last cell stays populated — the lesson comes back to that below.
Hence the price. Measured on a slice of 10,000 elements:
| what we do | elements shifted | time |
|---|---|---|
| delete the first | 9,999 | 1,401 ns |
| delete the middle | 4,999 | 667 ns |
| delete the second-to-last | 1 | below resolution |
shorten from the end, s = s[:len(s)-1] | 0 | below resolution |
The linearity is visible in the numbers themselves: 1401 to 667 is roughly 9999
to 4999. The cost depends not on the slice length but on the length of the
tail that has to be shifted. Deleting from the front is O(n), from the back
O(1), and it is the same expression at different i.
The two bottom rows carry no number not because the measurement is missing but because that is where it ends: shifting one element and shifting none cost less than preparing the slice itself, and subtracting the baseline disappears into noise. Substantively that is the same answer — their cost does not grow with the length of the slice.
Worth naming the practical consequence: if element order does not matter, deletion is O(1) by swapping in the last element:
s[i] = s[len(s)-1]
s = s[:len(s)-1]And a separate trap — the one that makes deletion leak. Shortening a slice reduces the length, but the array is intact and the element past the length is still in it. If that element is a pointer, the object behind it will not be collected:
live := []*Node{a, b, c}
live = live[:2] // c is no longer visible... but the array holds itVerified by running it: the third element stays in the array and stays non-nil.
slices.Delete solves this itself — the documentation says outright that it
zeroes the elements s[len(s)-(j-i):len(s)]
. Through append, zeroing the
tail is on you.
Mechanism 4: where a slice pins memory
The same mechanism, at a scale that costs money:
func firstThree(path string) []byte {
data, _ := os.ReadFile(path) // 50 MB
return data[:3] // three bytes handed back
}Three bytes returned — fifty megabytes still resident. The garbage collector works with whole arrays: while the pointer to the start is alive, so is the entire array. A narrower slice does not remove that reference, it only reduces the length.
A copy does:
return slices.Clone(data[:3])slices.Clone returns a copy of the slice
, and after it nothing pins the
original array. The caveat in the documentation matters: the copy is
shallow, and if the elements point at something themselves, that something
stays shared.
This, incidentally, is the best answer to "when did you last chase a leak in Go": most leaks in Go are not a forgotten goroutine but exactly this kind of pinned array.
Mechanism 5: range copies — a guarantee and an observation
This section carries two claims of different kinds, and the lesson deliberately keeps them apart: one is always true, the other is true on a particular machine under a particular workload. Mixing them is how superstitions get made.
v is a copy of the element, and writing to it does not change the slice.
for _, v := range items {
v.done = true // lost: v is a copy
}
for i := range items {
items[i].done = true // this way
}This is not about speed and not about the Go version. It is about range by
value assigning the element to the loop variable, and assignment in Go
copies. The resulting bug compiles without a warning and passes the tests: the
code honestly changes a copy.
But the conclusion "so range by value is slower" is not what the measurement shows.
for _, v := range xs 21,782 ns — a 64-byte struct is copied
for j := range xs 22,577 ns — no copy
By value came out faster, a ratio of 0.96. The reason is that the indexed
form has a cost of its own: every xs[j] access is bounds-checked, while
range by value walks memory in order without checks. The two costs turned out
to be of the same order, and which one wins depends on the element size, on what
the loop body does, and on whether the compiler managed to eliminate the bounds
check.
This number must not be generalised in either direction: on a kilobyte struct the copy will surely win. What carries meaning here is something else — that the difference is not an order of magnitude, which means the loop form should be chosen by semantics, not by speed.
In an interview this is a good move: name the copying as a guarantee and the performance as a measurement, and do not conflate them. That is exactly how it stands.
Mechanism 6: nil and empty slices
var a []int // nil
b := []int{} // not nilBoth have length zero, append works with either, for range takes no step.
There are exactly two differences, and both surface far from where the slice was
created:
var a []int | []int{} | |
|---|---|---|
a == nil | true | false |
json.Marshal | null | [] |
The second is a source of real bugs: a client expecting an array gets null and
falls over. In the code it looks like "but we returned an empty list".
The rule: do not distinguish them inside your code — test len(s) == 0, not
s == nil — and initialize explicitly at the JSON boundary.
Deeper: capacity growth and the price of "amortized O(1)"
From here on the claims change kind, and the difference is worth saying out loud. Everything above described the language contract: it will not change. Everything below about specific growth numbers is how today's implementation works, and it has changed before.
This is where the most commonly memorized rule lives — and it is wrong. Here are
the capacities a []int really passes through under one-by-one append:
1 2 4 8 16 32 64 128 256 512 848 1280 1792 2560 3408 5120 7168 9216 12288 16384 21504
Doubling ends not at 1024 but at 512: the next number is 848. Here is the relevant piece of the runtime, in full:
const threshold = 256
if oldCap < threshold {
return doublecap
}
for {
// Transition from growing 2x for small slices
// to growing 1.25x for large slices.
newcap += (newcap + 3*threshold) >> 2
if uint(newcap) >= uint(newLen) {
break
}
}Take it apart, because both "known" numbers break right here.
The threshold is 256, not 1024. While the old capacity is below 256, plain
doubling is returned. At oldCap == 256 the formula takes over, but it yields
256 + (256 + 768)/4 = 512 — so the doubling "accidentally" continues for one
more step. The first number where the divergence shows is 848 after 512.
The factor is not 1.25. The formula adds a quarter of the capacity plus
192 (that is 3*256/4). At capacity 512 that gives
512 + (512 + 768)/4 = 832, a factor of 1.63. The share contributed by the 192
falls as the capacity grows, so the series does converge to 1.25 — but it
approaches from above, and slowly:
2.00 … 2.00 1.66 1.51 1.40 1.43 1.33 1.50 1.40 1.29 1.33 1.33 1.31
Notice that the series is not monotonic. That is a third effect — rounding to size classes. The formula asked for 832 elements, that is 6656 bytes; the allocator handed back the nearest class, 6784 bytes — which is 848 elements. The runtime counts in bytes, and dividing back into elements leaves a remainder.
The easiest way to see it is a type whose size is not a power of two. Here is
the same append for a 15-byte struct:
1 2 4 8 16 32 68 136 273 546 904 1365 1911 2730
Sixty-eight. Two hundred and seventy-three. Nobody designed those numbers — they fell out of dividing bytes by fifteen.
What you need from this in an interview. Not the sequence by heart, but
three claims: growth is geometric with a decreasing factor; the threshold is
256; specific cap values are not guaranteed, because the arithmetic is done in
bytes. The last one is the most useful: code that relies on cap equalling some
number will break on a Go upgrade or an element-type change.
Complexity, and what hides behind it
"Amortized O(1)" is the right answer, usually delivered as a password. Behind it stand two different claims, and separating them out loud is valuable.
First: the total work is linear. Reallocations happen ever more rarely, and the copied volumes add up as a geometric series. So building a slice of n elements costs O(n), not O(n²) as it would with growth by one.
Second: an individual call can cost O(n). The append on which the
reallocation happens copies the whole array. Irrelevant for the mean;
potentially relevant for tail latency.
"Potentially" here is precision, not caution. The spike shows up in p99 only
where the reallocation is large and sits on the request's hot path: the slice
grows to tens of thousands of elements inside the handler. If the slice is
built once at startup, or its length is in the hundreds, or the reallocations
happen outside the request, nothing appears in the tail at all. This is checked
with a profile, not with arithmetic: "append hurts p99" is a claim about a
workload, not about append.
Look at both halves at once. The line is total copying, the vertical ticks are reallocations:
And now the part that usually goes untold. The coefficient on n is not one. Here is the measurement: how many elements the runtime copied in total while the slice grew to n:
| n | reallocations | copied | times n |
|---|---|---|---|
| 1,000 | 12 | 1,871 | 1.87 |
| 100,000 | 28 | 402,079 | 4.02 |
| 1,000,000 | 38 | 4,154,015 | 4.15 |
The coefficient grows with size — and that is not a defect but a direct
consequence of the previous section. A geometric series with ratio r sums to
n/(r−1): at doubling that is ≈1·n, at a factor of 1.25 it is already ≈4·n. At
a thousand elements the runtime is still doubling, hence 1.87; at a million the
slow formula has long been in charge, hence 4.15.
That is the real trade built into nextslicecap: slower growth saves memory
(less unused capacity) and pays for it in copying (four times as many elements
moved). Neither is "optimization in general" — it is a choice in favour of
memory.
And hence the answer to "how do I remove that": make([]T, 0, n) when the final
size is known. Not "to make append faster", but so that there are no
reallocations at all — in the task at the end of this lesson that came out as a
sevenfold difference.
How to answer in an interview
Short answer: a slice does not own its data — it is a window onto somebody
else's array, and two windows can look at the same cells. That is why a write
through one slice is visible in another, and why append sometimes writes into
that same array and sometimes moves to a new one — which is exactly why it
returns a slice whose result you assign back.
Start with ownership rather than layout: the three-word header explains why the window is built that way, but on its own it does not answer the question. After that pair half the follow-up questions are already answered, and it shows.
That is enough for a correct answer. What follows is what you add when the interviewer digs.
If the interviewer digs deeper
Keep a one-minute account of append ready. Two paths; on the fast one, a
write into an existing array; on the slow one, a new array and a copy; it
returns a slice for exactly that reason. This is the core of the topic and it
always comes up.
On growth, be honest rather than confident. "It doubles at small sizes and
grows more slowly after; the threshold is 256; the factor just above it is
noticeably larger than 1.25 and only converges there; specific cap values are
not guaranteed because the arithmetic is in bytes" is the answer of someone who
has read nextslicecap. "×2 up to 1024, then ×1.25" is an answer from somebody
else's retelling, and one follow-up exposes it.
Split the complexity in two. "Amortized O(1) is about the sum. A single call at a reallocation costs O(n), and for p99 that matters." Almost nobody states that split, and it shows there is understanding behind the formula.
Answer "how do you avoid it" with forms, not intentions. Three of them:
make([]T, 0, n) to remove reallocations; s[a:b:b] to cut capacity before
handing a slice outward; slices.Clone to stop pinning a large array.
Next they ask
If append can write into the same array, why does it return a slice at all?
Because in its second case — when capacity ran out — the array is new, and the old header no longer points at it. A function cannot change the caller's header: it was passed by value. Returning the new pointer and length is the only way to report them.
Hence the rule "always assign the result of append". Go's compiler will not
let you drop the result of a call — but it will happily let append(s[:0], x...)
land in somebody else's variable.
Then why not always grow by doubling — surely that copies less?
It does, and that is measurable: doubling copies ≈1·n in total, a factor of 1.25 copies ≈4·n. But the price of doubling is memory: a slice of a million elements may hold an array of two million, and half of that is never used. For long-lived structures in a service that is worse than the extra copying.
nextslicecap is exactly that compromise: doubling while the slice is small and
the waste is invisible, slower growth beyond, where the waste is measured in
megabytes. That is the point of the question — will you see that it is a trade
rather than an "optimization".
Is copy really faster than append in a loop?
The difference is not the cost of writing but the number of moves. append
without a preset capacity makes the runtime allocate a new array several times
and copy everything already laid down. When the final size is known,
make([]T, 0, n) removes those moves — in the measurement at the end of this
lesson that was a sevenfold difference over a hundred thousand elements.
copy is not an alternative to append but a different operation: it does not
grow a slice, it fills the length that already exists, and returns the number of
elements copied — the minimum of the two lengths. Forgetting that minimum is its
own source of quiet bugs: copy(dst, src) into a zero-length slice copies
nothing and does not complain.
What happens if you append to a slice inside a loop over that same slice?
for i := range s evaluates the length once, at the start — so the loop will
not become infinite however much you add. But
for i := 0; i < len(s); i++ re-reads len(s) on every step, and that loop
will not end.
The second half of the trap: for i, v := range s with an append inside may
hand you values from the old array if a reallocation happened. The range
captured the original header, while s already points at new memory.
Why does cap sometimes come out as a number nobody asked for?
Because the runtime counts in bytes: the required size is rounded up to the
allocator's next size class, and dividing back into elements leaves a remainder.
That is why the []int series contains 848 rather than 832 (the formula asked
for 6656 bytes, the allocator handed back 6784), and why a 15-byte struct grows
through 68, 136, 273.
The practical consequence: specific cap values cannot be relied on. What is
guaranteed is cap >= len and amortized constant time for append, not a
sequence of numbers.
Is a slice a reference type?
The phrasing is unfortunate and best avoided — but you should understand what is being asked. Go has no reference types in the C++ sense: everything is passed by value. A slice is passed by value too — its value simply happens to be a header with a pointer inside.
The difference is not verbal. "Reference type" predicts that a function will be
able to lengthen the slice for the caller — and it cannot. The correct phrasing
explains both why s[0] = 1 is visible outside and why an append inside a
function is not.
Common misconceptions
a slice is a dynamic array
A slice is a header over an array: pointer, length, capacity — 24 bytes on a 64-bit machine. The array does not belong to it and may belong to several slices at once. None of the behaviours you will be asked about next — shared writes, pinned memory, the nil/empty difference — follows from the dynamic-array analogy.
append always allocates a new array, so the original data is safe
Only when capacity ran out. The specification names both cases: Otherwise, append re-uses the underlying array
. A slice taken as s[1:3] from a five-element one has capacity four — and append will quietly write into the original array. The only way to cut that capacity off is the three-index form s[1:3:3].
capacity doubles up to 1024 and then grows by a quarter
Neither number is right. The threshold in nextslicecap on go1.24 is 256, not 1024: the observed series runs 256, 512, 848. The factor just above the threshold is 1.66, not 1.25: the formula adds a quarter plus 192, and the ratio only converges to 1.25. The runtime's comment does say "1.25x" — but it is about the limit, and the memorized rule grew out of reading the comment instead of the code.
amortized O(1) means append is always cheap
Amortization is a claim about the sum. An individual append at a reallocation copies the whole array and costs O(n); on the chart that is a vertical step. Irrelevant for the mean, relevant for p99. And the sum is not free either: measured, growing to a million copies 4.15·n elements, not n.
deleting from a slice is just an append, and costs the same everywhere
The expression is the same, the cost is not. Measured on 10,000 elements: deleting the first costs 1401 ns, the middle 667, while the second-to-last falls below the resolution of the measurement. The cost is proportional to the tail that has to be shifted, not to the slice length. And when order does not matter, the same thing is O(1) by swapping in the last element.
range by value is slower because it copies the element
It does copy, and that matters semantically: writing to v does not change the slice. But the speed conclusion does not follow. Measured on 64-byte structs, one machine and one run: 21,789 ns for range by value against 23,157 ns for the indexed form — by value was actually faster, because the indexed form pays a bounds check on every access.
returning a small piece of a large slice releases the memory
It does not. The garbage collector deals in whole arrays, and the pointer to the start is alive as long as any slice over it is. Three bytes returned from a fifty-megabyte file keep all fifty megabytes. What releases them is a copy — slices.Clone — not a narrower slice.
a nil slice and an empty slice are the same thing
For len, append and range they are, which is exactly why the difference surfaces far from where the slice was created. a == nil tells them apart, and so does json.Marshal: null against []. A client expecting an array gets null — while in the code it looks like "we returned an empty list".
Practice
Two tasks. Answer first, then check against the real output: in both, the correct answer is taken from a script run rather than assigned.
Practice · predict the output
s := []int{1, 2, 3, 4, 5}
t := s[1:3]
fmt.Println(len(t), cap(t))
t = append(t, 99)
fmt.Println(s)
fmt.Println(t)Practice · estimate
Knowledge check
A function takes a slice and does s[0] = 42, returning nothing. Does the caller see the change?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- A slice does not own its data — it is a window onto somebody else's array. Two windows can look at the same cells, and a write through one is visible in the other: the run prints how a single write changes three values — both slices and the array itself. That is a language guarantee, not an implementation detail. And
appendeither writes into that same array or moves to a new one — which is why it returns a slice. - Hence the main consequence:
appendcan silently write into somebody else's cells. The capacity ofs[1:3]is not two: by the specification it iscap(s) − low, running to the end of the array — soappendinto such a slice writes where the neighbour's data lives. The same shared storage is what pins memory: three bytes kept from a 50 MB file keep all fifty. And deletion costs not by the length of the slice but by the tail that has to be shifted: measured at 1401 and 667 ns for shifting 9999 and 4999 elements, while at one element and none the difference already falls below the resolution of the measurement. - Beyond that come the numbers, the versions and their limits. A slice is a three-word header (a pointer, a length, a capacity); measured, that is 24 bytes, and those are what gets copied when it is passed to a function, however many elements the array holds, while
[5]intis 40 bytes because an array is a value. The rule "×2 up to 1024, then ×1.25" is wrong in both numbers: on go1.24 the threshold is a named constant 256 innextslicecap, and the factor just above it is 1.66, because the formula adds a quarter plus 192 — and specificcapvalues are not guaranteed at all. "Amortized O(1)" is about the sum, not about each call: measured, growing to a million copies 4.15·n elements, while a singleappendat a reallocation costs O(n). Andfor _, v := rangecopies the element — but that is an argument about semantics, not speed: in the same measurement it came out faster than the indexed form.
In fact
- A slice is a header over an array: pointer, length, capacity — 24 bytes on a 64-bit machine. The array does not belong to it and may belong to several slices at once. None of the behaviours you will be asked about next — shared writes, pinned memory, the
nil/empty difference — follows from the dynamic-array analogy. - Only when capacity ran out. The specification names both cases: Otherwise, append re-uses the underlying array. A slice taken as
s[1:3]from a five-element one has capacity four — andappendwill quietly write into the original array. The only way to cut that capacity off is the three-index forms[1:3:3]. - Neither number is right. The threshold in
nextslicecapon go1.24 is 256, not 1024: the observed series runs 256, 512, 848. The factor just above the threshold is 1.66, not 1.25: the formula adds a quarter plus 192, and the ratio only converges to 1.25. The runtime's comment does say "1.25x" — but it is about the limit, and the memorized rule grew out of reading the comment instead of the code. - Amortization is a claim about the sum. An individual
appendat a reallocation copies the whole array and costs O(n); on the chart that is a vertical step. Irrelevant for the mean, relevant for p99. And the sum is not free either: measured, growing to a million copies 4.15·n elements, not n. - The expression is the same, the cost is not. Measured on 10,000 elements: deleting the first costs 1401 ns, the middle 667, while the second-to-last falls below the resolution of the measurement. The cost is proportional to the tail that has to be shifted, not to the slice length. And when order does not matter, the same thing is O(1) by swapping in the last element.
- It does copy, and that matters semantically: writing to
vdoes not change the slice. But the speed conclusion does not follow. Measured on 64-byte structs, one machine and one run: 21,789 ns for range by value against 23,157 ns for the indexed form — by value was actually faster, because the indexed form pays a bounds check on every access. - It does not. The garbage collector deals in whole arrays, and the pointer to the start is alive as long as any slice over it is. Three bytes returned from a fifty-megabyte file keep all fifty megabytes. What releases them is a copy —
slices.Clone— not a narrower slice. - For
len,appendandrangethey are, which is exactly why the difference surfaces far from where the slice was created.a == niltells them apart, and so doesjson.Marshal:nullagainst[]. A client expecting an array getsnull— while in the code it looks like "we returned an empty list".
What is covered
- What is actually being asked
- Base: a slice is a window onto somebody else's array
- Mechanism 1: slices, arrays and strings are three different things
- Mechanism 2: what append does
- Mechanism 3: deletion, insertion and the price of shifting
- Mechanism 4: where a slice pins memory
- Mechanism 5: range copies — a guarantee and an observation
- Mechanism 6: nil and empty slices
- Deeper: capacity growth and the price of "amortized O(1)"
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
3 SOURCES
- The Go Programming Language Specification — Slice types, Slice expressions, Appending to and copying slicesOfficial documentation. The definition: “A slice is a descriptor for a contiguous segment of an underlying array and provides access to a numbered sequence of elements from that array”. Shared ownership is stated outright: “A slice, once initialized, is always associated with an underlying array that holds its elements. A slice therefore shares storage with its array and with other slices of the same array”. From the same page, the capacity rule for `a[low:high]` — “the capacity is cap(a) - low” — and for the three-index form `a[low:high:max]`, which “sets the capacity to max - low”. And the behaviour of append: “If the capacity of s is not large enough to fit the additional values, append allocates a new, sufficiently large underlying array that fits both the existing slice elements and the additional values. Otherwise, append re-uses the underlying array”.https://go.dev/ref/spec#Slice_types
- runtime/slice.go — nextslicecap at go1.24.7Go source code. The function that actually decides the new capacity. The threshold is a named constant in it: “const threshold = 256”, and below it the result is plain doubling — “if oldCap < threshold { return doublecap }”. Above it the formula is “newcap += (newcap + 3*threshold) >> 2”, with the authors' comment: “Transition from growing 2x for small slices to growing 1.25x for large slices. This formula gives a smooth-ish transition between the two”. The comment describes the limit, not the next steps: at capacity 512 the same formula yields a factor of 1.625, and only on long slices does it converge to 1.25. The same file shows the requested size then being rounded up to an allocator size class — hence 848 rather than 832. Verified by running on go1.24.7.https://github.com/golang/go/blob/go1.24.7/src/runtime/slice.go
- Package slices — Clone and DeleteOfficial documentation. The standard answer to a small slice pinning a large array: “Clone returns a copy of the slice. The elements are copied using assignment, so this is a shallow clone. The result may have additional unused capacity”. The word “shallow” matters: the elements are copied, not what they point to. For Delete the documentation warns about the tail outright — “Delete zeroes the elements s[len(s)-(j-i):len(s)]” — that is, it does by hand exactly what people forget when deleting through append.https://pkg.go.dev/slices#Clone