Slices in Go: three words, a shared array, and capacity growth by a rule other than the one everyone remembers
A slice is a three-word header over somebody else's array. From that follows both the fact that append sometimes changes a neighbouring slice, and the fact that a ten-byte piece keeps fifty megabytes alive. And the rule “×2 up to 1024, then ×1.25” describes the runtime before Go 1.18 — and was not exact even then.
Full technical treatment
TL;DR
- A slice is three machine words: pointer, length, capacity. 24 bytes, no matter how many elements.
- The header is copied, the array is not. Everything else follows.
appendinto a sub-slice can clobber a neighbour:a[:2]has capacity all the way to the end of the array. The cure is the third index,a[:2:2].- Growth is not “×2 up to 1024, then ×1.25”: doubling stops at 256, and past that the ratio is not constant — 1.656 → 1.509 → 1.400 → 1.429 → 1.331 → 1.502.
- No reserved capacity is expensive: 10,000 elements take 19 allocations against 1.
- A 10-byte piece keeps a 50 MB array alive.
Three words
unsafe.Sizeof([]int{}) // 24 — pointer, length, capacity
unsafe.Sizeof([8]int{}) // 64 — this is an array; it holds the elementsAn array is a value and is copied whole. A slice is a reference: 24 bytes for any length.
The header is copied, the array is not
The first consequence of the three words.
func grow(s []int) { s = append(s, 42) } // invisible outside
func set(s []int) { s[0] = 7 } // visibleThe function got a copy of three words. The pointer is the same, so the write
lands. The new length stayed in the copy. That is where s = append(s, x)
comes from.
The shared array
The array is shared — what remains is how far.
a := []int{1, 2, 3, 4, 5}
b := append(a[:2], 9)
// b = [1 2 9]
// a = [1 2 9 4 5] <- clobbereda[:2] has length 2 but capacity 5 — capacity runs to the end of the
array, not to the end of the slice. append saw a free cell and wrote into it.
Nothing went wrong: it did exactly what it promises.
The same call with capacity exhausted allocates a new array and touches nothing. The code at the call site does not show which.
How to fix it
b := a[:2:2] // the third index limits CAPACITY: length 2, capacity 2
b = append(b, 9)
// a = [1 2 3 4 5] — intactWith no capacity, append has to allocate its own array. That is how you hand
a piece of your buffer outwards.
Capacity growth
The folklore “double up to 1024, then 1.25×” describes the runtime before
Go 1.18. What runtime/slice.go has now is const threshold = 256, and the
formula past the threshold is newcap += (newcap + 3*threshold) >> 2.
The measured ratios for []int: 1.656 → 1.509 → 1.400 → 1.429 → 1.331 → 1.502.
Not 1.25 and not even monotonic — because the result of the formula is rounded
up to the nearest size class: the Go allocator hands out blocks not of any
size but from a fixed set (8, 16, 24, 32, 48, 64, … 2688), and the set is
defined in bytes.
Clearest on a slice of 40-byte structs: cap goes 32 → 67 → 134 → 272,
and on to 544 → 1024 → 1638. Nobody asked for 67: the formula requested 64
elements, that is 2560 bytes; the nearest class is 2688, and 67 elements of 40
bytes fit into it.
What it costs
Building a []int of 10,000:
- with no reserved capacity — 19 allocations, 357,624 bytes;
make([]int, 0, N)— 1 allocation, 81,920 bytes.
The useful data is 80,000 bytes. The rest is intermediate arrays.
The three ways of reserving capacity (make(…, 0, N), make(…, N),
slices.Grow) are indistinguishable from each other. What matters is that
capacity is reserved at all.
Two more rows from the same run:
copy— 1,668 ns, a hand-written loop — 6,163 on the same data;rangeby value — 32,028 ns, by index — 6,206 (a slice of 136-byte structs; on a[]intthere is no difference).
Leaking through a sub-slice
a 10-byte slice of a 50 MB array: 50.1 MB on the heap
after copy into its own array AND dropping it: 0.1 MB on the heap
The collector works on whole arrays. If you return a small piece of a large
buffer, return a copy (slices.Clone).
nil and empty
var n []int // n == nil, json -> null
e := []int{} // e != nil, json -> []For len, range and append they are indistinguishable, and if s == nil
is almost always a redundant check — what you want is if len(s) == 0. The
difference surfaces in serialization: a client expecting an array gets null.
TL;DR
A slice is three machine words: a pointer to an array, a length and a capacity. Everything else follows from that.
- The header is copied, the array is not. That is why an
appendinside a function is invisible outside it, while a write to an element is visible. appendinto a sub-slice can clobber a neighbour.a[:2]has length 2 but capacity all the way to the end of the array;appendsees a free cell and writes into it. The same call with capacity exhausted allocates a new array — and the code at the call site does not show which will happen.- The cure is the third index:
a[:2:2]takes the capacity away, andappendhas to allocate its own. - Capacity growth does not work the way people think. Doubling stops at
256, not at 1024, and past that the ratio is neither 1.25 nor even
constant: 1.656 → 1.509 → 1.400 → 1.429 → 1.331 → 1.502. The cause is rounding up
to an allocator size class, which is why
capsometimes comes out as 67 or 848. - What that costs: building 10,000 elements with no reserved capacity takes 19 allocations and 357,624 bytes, against 1 allocation and 81,920 bytes with it.
- A small slice keeps a big array alive. A 10-byte piece of a 50 MB array leaves all 50 MB on the heap.
Three words
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.
A descriptor is literally a struct of three fields, and you can ask the compiler for its size:
unsafe.Sizeof([]int{}) // 24 — pointer, length, capacity
unsafe.Sizeof("") // 16 — a string has two fields
unsafe.Sizeof([8]int{}) // 64 — this is an array; it holds the elementsAn array in Go is a value: [8]int takes 64 bytes and is copied whole. A slice
is a reference: 24 bytes no matter how many elements sit behind it. That has a
consequence the specification also states 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.
The header is copied, the array is not
The first consequence of the three words — and the most common place to trip over them. What is copied is those three words, not the array behind them.
func grow(s []int) { s = append(s, 42) } // invisible outside
func set(s []int) { s[0] = 7 } // visible outsideThe function got a copy of three words. The pointer in the copy is the same, so a write to an existing element lands in the shared array. But the new length stayed in the copy and died with it.
And the element was written: if the slice had capacity, 42 is sitting in
the array, just past the length. Call grow on s := make([]int, 3, 8) and
s[:cap(s)][3] will show 42 there, while len(s) is still 3. That is exactly
why the rule is s = append(s, x) and not “remember the pointer”: the pointer
has nothing to do with it, the length does.
Capacity runs to the end of the array
So the array is shared. What remains is to find out how far it is shared — and that is where the most common stumble lives.
This is where people trip most often.
b := a[:2] has length 2 — that much is obvious. But b's capacity is
5, not 2:
the capacity is cap(a) - low
That is, capacity runs to the end of the array, not to the end of the
slice: cap(a) is 5 and low is zero. Between length 2 and capacity 5 sit
three cells that are free as far as b is concerned and occupied as far as
a is. The rest is just reading what
append does:
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.
The word “otherwise” is the whole story:
a := []int{1, 2, 3, 4, 5}
b := append(a[:2], 9)
// b = [1 2 9]
// a = [1 2 9 4 5] <- the third element is clobberedNothing went wrong. append did exactly what it promises: it saw a free cell
and wrote into it.
The nastiest part is that the same call behaves differently when there is no capacity.
c := []int{1, 2}
d := append(c, 9) // no capacity -> a new array, c untouchedThe same append: in the first case two slices share an array, in the second
they do not. The code at the call site does not show which, because it depends
not on the code but on how the slice was obtained.
How to hand out a piece and not worry
That is what the full slice expression is for. The specification puts it briefly:
sets the capacity to max - low
b := a[:2:2] // length 2, capacity 2
b = append(b, 9)
// a = [1 2 3 4 5] — untouchedThe third index limits the capacity, not the length. With no capacity,
append has to allocate a new array.
This is the only way to hand a piece of your buffer outwards without copying anything: not by asking nobody to append, but by making an append into your array physically impossible. A copy is the other way, and it protects against more; what exactly separates the two is taken apart in the next section.
Clip and Clone: which one protects against what
You do not have to write the three-index form by hand: the standard library
keeps it under the name slices.Clip, with slices.Clone beside it. Both functions are short enough that their bodies are the
whole answer:
// Clip removes unused capacity from the slice, returning s[:len(s):len(s)].
func Clip[S ~[]E, E any](s S) S {
return s[:len(s):len(s)]
}
// Clone returns a copy of the slice.
func Clone[S ~[]E, E any](s S) S {
if s == nil {
return nil
}
return append(S{}, s...)
}Clip is exactly the expression from the previous section and does not copy
at all.
Clone is an append into an empty slice, which means copying every time. Hence
the distinction people mix up most often (bench/goslice/clip.go):
Clip(head) and buf share: THE SAME array <- Clip does NOT copy
Clone(head) and buf share: a different one <- Clone copies right away
(The script prints its labels in Russian; here and in the two blocks below they are translated, and only the values are as printed.)
Clip protects against one thing and one thing only: append will no longer
reach into someone else's tail.
buf AFTER append(head,99) len=5 cap=5 [1 2 3 99 5]
buf AFTER append(Clip(head),99) len=5 cap=5 [1 2 3 4 5]
On the first line append wiped out someone else's element; on the second the
array is intact, because Clip forced append to allocate its own.
Against a write by index it does not protect at all — it is the same array:
buf after clipped[0]=777 len=5 cap=5 [777 2 3 4 5]
buf after cloned[0]=777 len=5 cap=5 [1 2 3 4 5]
And — this bears directly on the section below, where a small slice keeps a big
array alive — Clip does not release the array. A length-3 slice of an array
of 1,048,576 elements has capacity 3 after Clip and holds the whole array: the
pointer has not changed. Against retention it is Clone that works, and only
Clone.
The rule worth taking away: Clip is about append, Clone is about ownership.
nil survives both — Clone has a branch for it with the authors' note,
Preserve nilness in case it matters
.
A shared array is shared memory
So far the shared array has been examined inside a single goroutine. Add a second one and the very same fact stops being a surprising result and becomes a data race.
Everything said above about a shared array gives, in a single-threaded program,
a surprising result. In a concurrent one it gives a data race — and not one
index looks suspicious while it does. Two cases, both breaking on nothing at all
(bench/goslice/race_shared.go):
buf[0:4]andbuf[2:6]— the ranges overlap,a[2]andb[0]are one and the same cell;- two slices of
len=1 cap=8over one array — bothappends write tobuf[1], that is, into shared capacity.
The second is the nastier one: there is no shared index in the code at all. What is shared is precisely what the source does not show.
The detector finds both and names both sides. The addresses and goroutine numbers change from run to run, and so does the order of the two stanzas; the source lines do not:
WARNING: DATA RACE
Write at 0x00c0000a6010 by goroutine 8:
main.overlap.func2()
.../racesrc/main.go:73
Previous write at 0x00c0000a6010 by goroutine 7:
main.overlap.func1()
.../racesrc/main.go:67
The exit status is 66, not 1 or 2 — a separate value set aside for exactly this:
exitcode (default 66): The exit status to use when exiting after a detected
race.
A mutex here is a half-measure rather than a cure. In the first case it does
remove the race. In the second, a lost write survives it: under the lock the two
appends put their values into the same buf[1] in turn, and the second wipes
out the first — the detector goes quiet and the data goes missing. What cures it
is the slicing: separate the ranges (buf[0:4] and buf[4:8]), or take away the
capacity with slices.Clip so that each append allocates its own. On both
fixed variants the detector is silent.
Silence, though, is not proof:
The race detector only finds races that happen at runtime, so it can't find
races in code paths that are not executed.
Capacity growth
Up to here the capacity has only been described as something that exists and runs to a certain point. Now: where it comes from.
The folklore rule is wrong here twice over — in the threshold and in the factor.
The folklore version is “double up to 1024 elements, then 1.25×”. It describes
the runtime before Go 1.18. What runtime/slice.go says now is:
if newLen > doublecap {
return newLen
}
const threshold = 256
if oldCap < threshold {
return doublecap
}
for {
newcap += (newcap + 3*threshold) >> 2
...
}The first line answers a question people usually do not even ask: if a single
append adds more than doubling would give, there is no factor at all — the
capacity is taken straight from the requested length.
The threshold is 256, and it is compared against the capacity, not the length. That is how it was changed:
Instead of growing 2x for < 1024 elements and 1.25x for >= 1024 elements, use a
somewhat smoother formula for the growth factor. Start reducing the growth
factor after 256 elements, but slowly.
But “1.25” is wrong too — even as a description of the current formula. The
measured ratios for []int run 1.656 → 1.509 → 1.400 → 1.429 → 1.331 →
1.502: not 1.25 and not even monotonic. There are two reasons, and both are
visible in the source.
The first is the formula itself. newcap += (newcap + 3*256) >> 2 is not a
multiplication by 1.25 but a quarter plus 192. The ratio only approaches 1.25
at large values; at 512 it gives 1.625.
The second is rounding. The Go allocator does not hand out blocks of
arbitrary size: it has a fixed set — 8, 16, 24, 32, 48, 64, … 2048, 2304,
2688 — and a request is rounded up to the nearest of them. Such a size is
called a size class. The result of the formula goes into roundupsize,
which rounds the size in bytes up to the nearest class. The author of
the change warned about this in the same commit message:
(Note that the real growth factor, both before and now, is somewhat larger
because we round up to the next size class.)
Which means a rule stated in elements cannot be exact in principle: the
runtime counts in bytes. It shows clearest on a slice of 40-byte structs:
cap goes 32 → 67 → 134 → 272 → 544 → 1024 → 1638. Nobody asked for
67: the formula requested 64 elements, that is 2560 bytes; the nearest class is
2688; and 67 elements of 40 bytes fit into it.
And for []int8 the capacity starts not at 1 but at 8: the allocator
simply does not hand out anything smaller than eight bytes.
What that growth costs
Building a []int of 10,000 elements:
- with no reserved capacity — 159,964 ns, 357,624 bytes, 19 allocations;
make([]int, 0, N)— 40,542 ns, 81,920 bytes, 1 allocation.
The useful data here is 80,000 bytes. Everything beyond that is intermediate arrays that had to be allocated, copied and thrown away.
Notice what is not in these numbers: any difference between the three ways
of reserving capacity. make([]T, 0, N) with append, make([]T, N) with
index writes and slices.Grow come out the same within the run-to-run spread.
There is nothing to choose between them on speed — what matters is that
capacity is reserved at all.
Two more rows from the same measurements. Neither of them allocates at all — and there is still a difference:
copyagainst a hand-written loop — 1,668 ns against 6,163 on the same 10,000 elements.copyandappend(dst[:0], src...)come down to the samememmove, while the loop copies eight bytes at a time with a bounds check.rangeby value againstrangeby index — 32,028 ns against 6,206 on a slice of 136-byte structs. Neither allocates: the entire fivefold difference is copying 136 bytes per iteration for the sake of the eight that are needed. On a[]intthis difference does not exist.
A small slice keeps a big array alive
The garbage collector works on whole arrays. While a single slice is alive, the whole array beneath it is alive:
a 10-byte slice of a 50 MB array: 50.1 MB on the heap
after copy into its own array AND dropping it: 0.1 MB on the heap
The second line needs both actions: the copy on its own frees nothing while the sub-slice is alive — and the sub-slice is what holds the array.
This is not an optimization but the difference between a working service and a leak. A function of the form “find the piece I need in this file I read” returns a sub-slice — and holds the whole file for as long as the result lives.
The official introduction names this outright and suggests the same cure:
return a copy, not a sub-slice. slices.Clone does it in one line.
nil and empty
var n []int // len 0, cap 0, n == nil, pointer 0x0
e := []int{} // len 0, cap 0, e != nil, pointer non-zeroThe empty slice has a non-zero pointer but no memory allocated for it: it points at a zero object shared across the whole program.
For len, range, append and copy the two are indistinguishable —
append to a nil slice works, and ranging over it does zero iterations. So an
if s == nil check is almost always redundant; what you want is
if len(s) == 0.
The difference surfaces in exactly one place — serialization:
json.Marshal(n) // null
json.Marshal(e) // []A client expecting an array gets null. That is the only practical reason to
tell them apart — and it is about your API, not about Go.
What to check in your own code
- Returning a sub-slice of someone else's buffer — limit the capacity with the third index, or return a copy.
- Know the size in advance — reserve capacity. Not for the nanoseconds but for the allocations: there will be one instead of nineteen.
- Holding a small piece of a big array — copy it, or the big array stays alive.
- Ranging over a slice of large structs — take the index, not the value.
- Sending a slice as JSON — decide whether
nullor[]is right there. - Handing pieces of one array to goroutines — separate the ranges or take
away the capacity: a shared array shows up under
-race, never by eye.
Reproducing the numbers
Three scripts contain no timing at all. bench/goslice/grow.go prints
addresses, lengths, capacities and heap sizes. bench/goslice/clip.go compares
Clip and Clone by array addresses. bench/goslice/race_shared.go builds a
companion program, bench/goslice/racesrc/main.go, under -race — that is the
code under test — and prints the detector's verbatim answer. All three run from
the repository root.
The cost measurement is bench/goslice/cost_test.go, four blocks. It is a
separate Go module, so go test is run from inside it:
go run bench/goslice/grow.go
go run bench/goslice/clip.go
go run bench/goslice/race_shared.go
cd bench/goslice
go test -run '^$' -bench . -benchmem .
Run it with -benchmem, or the column that matters will not be there.
The published run: go1.24.7 linux/amd64, Intel Xeon 2.80 GHz, August 2026. The
timings depend on the machine; the B/op and allocs/op columns do not — they
did not change from run to run at all. The growth figures in grow.go are
counted by growth steps rather than by a heap counter, so they reproduce to the
byte; the cross-check against runtime.MemStats is printed beside them and
does drift — which is what makes it a cross-check.
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 is three machine words: a pointer to an array, a length and a capacity. Everything else follows from that.
- The header is copied, the array is not. That is why an
appendinside a function is invisible outside it, while a write to an element is visible. appendinto a sub-slice can clobber a neighbour.a[:2]has length 2 but capacity all the way to the end of the array;appendsees a free cell and writes into it. The same call with capacity exhausted allocates a new array — and the code at the call site does not show which will happen.- The cure is the third index:
a[:2:2]takes the capacity away, andappendhas to allocate its own. - Capacity growth does not work the way people think. Doubling stops at 256, not at 1024, and past that the ratio is neither 1.25 nor even constant: 1.656 → 1.509 → 1.400 → 1.429 → 1.331 → 1.502. The cause is rounding up to an allocator size class, which is why
capsometimes comes out as 67 or 848. - What that costs: building 10,000 elements with no reserved capacity takes 19 allocations and 357,624 bytes, against 1 allocation and 81,920 bytes with it.
- A small slice keeps a big array alive. A 10-byte piece of a 50 MB array leaves all 50 MB on the heap.
In fact
- The threshold is 256, and it sits in
runtime/slice.goasconst threshold = 256. The 1024 version describes the runtime before Go 1.18; the release notes for that version say so themselves: The built-in function append now uses a slightly different formula when deciding how much to grow a slice. But “1.25” is inexact too: the formulanewcap += (newcap + 3*threshold) >> 2is a quarter plus 192, and the ratio only approaches 1.25. Measured for[]int: 1.656 → 1.509 → 1.400 → 1.429 → 1.331 → 1.502. Neither a constant factor nor a monotonic one. - The specification counts differently: the capacity is cap(a) - low. That is, to the end of the ARRAY, not the end of the slice: for
a[1:3]of[]int{1,2,3,4,5}the capacity is 4. Between the length and the capacity sit cells this slice considers free and a neighbouring one considers its own; that gap is what the whole clobbering story is built on. - Exactly the opposite: Otherwise, append re-uses the underlying array. Reproduced:
b := append(a[:2], 9)witha = [1 2 3 4 5]leavesa = [1 2 9 4 5], and the address of their first element is the same. Worse, the very same call behaves differently: with capacity exhausted it allocates a new array and touches nothing. The code at the call site cannot tell the two apart. - It limits the CAPACITY: sets the capacity to max - low. The length is set by the second index, which is present in that notation too. The point of the third one is the capacity: with no capacity
appendhas to allocate a new array, and that is the only way to hand a piece of your buffer outwards without relying on the caller's discipline. - Measured on 10,000 elements: 39,154 ns against 40,542, at the same 81,920 B/op and one allocation — a difference inside the run-to-run spread, where individual values run from 33,779 to 43,558. There is nothing to choose between them on speed. There is something to choose on behaviour:
make([]T, N)gives a slice already filled with zeros, and anappendto it writes AFTER them. - It occupies as much as the array beneath it, and that array can be anything. Measured: a 10-byte slice of a 50 MB array leaves 50.1 MB on the heap; after a
copyinto its own array AND dropping the sub-slice, 0.1 MB — the copy alone frees nothing while the sub-slice is alive. The garbage collector works on whole arrays: while a single slice is alive, the whole array is alive. Which is why a function returning a piece it found in a large buffer has to return a copy. - For
len,cap,range,appendandcopy— yes, indistinguishable, which is whyif s == nilis almost always a redundant check. But their pointers differ: nil has a zero one,[]int{}does not (a shared zero object, with no memory allocated for it). The difference shows up in exactly one place, serialization:json.Marshalgivesnullagainst[], and a client expecting an array gets the wrong thing. - It does, if you take the value. Measured on a slice of 136-byte structs:
for _, r := rangetakes 32,028 ns,for j := rangetakes 6,206 — fivefold. Neither allocates: the copy goes on the stack. But 136 bytes are copied per iteration for the sake of the eight that are needed. On a[]intthe difference does not exist: there the element fits in a register anyway.
What is covered
- Three words
- The header is copied, the array is not
- Capacity runs to the end of the array
- How to hand out a piece and not worry
- `Clip` and `Clone`: which one protects against what
- A shared array is shared memory
- Capacity growth
- What that growth costs
- A small slice keeps a big array alive
- nil and empty
- What to check in your own code
- Reproducing the numbers
Common misconceptions
A slice doubles up to 1024 elements, then grows by 1.25×
The threshold is 256, and it sits in runtime/slice.go as const threshold = 256. The 1024 version describes the runtime before Go 1.18; the release notes for that version say so themselves: The built-in function append now uses a slightly different formula when deciding how much to grow a slice
. But “1.25” is inexact too: the formula newcap += (newcap + 3*threshold) >> 2 is a quarter plus 192, and the ratio only approaches 1.25. Measured for []int: 1.656 → 1.509 → 1.400 → 1.429 → 1.331 → 1.502. Neither a constant factor nor a monotonic one.
The capacity of a[1:3] is 2 — the number of elements in the slice
The specification counts differently: the capacity is cap(a) - low
. That is, to the end of the ARRAY, not the end of the slice: for a[1:3] of []int{1,2,3,4,5} the capacity is 4. Between the length and the capacity sit cells this slice considers free and a neighbouring one considers its own; that gap is what the whole clobbering story is built on.
append always returns a new array, so the original slice is safe
Exactly the opposite: Otherwise, append re-uses the underlying array
. Reproduced: b := append(a[:2], 9) with a = [1 2 3 4 5] leaves a = [1 2 9 4 5], and the address of their first element is the same. Worse, the very same call behaves differently: with capacity exhausted it allocates a new array and touches nothing. The code at the call site cannot tell the two apart.
The full slice expression a[:2:2] limits the length
It limits the CAPACITY: sets the capacity to max - low
. The length is set by the second index, which is present in that notation too. The point of the third one is the capacity: with no capacity append has to allocate a new array, and that is the only way to hand a piece of your buffer outwards without relying on the caller's discipline.
make([]T, N) with index writes is faster than make([]T, 0, N) with append
Measured on 10,000 elements: 39,154 ns against 40,542, at the same 81,920 B/op and one allocation — a difference inside the run-to-run spread, where individual values run from 33,779 to 43,558. There is nothing to choose between them on speed. There is something to choose on behaviour: make([]T, N) gives a slice already filled with zeros, and an append to it writes AFTER them.
A slice of ten elements occupies memory for ten elements
It occupies as much as the array beneath it, and that array can be anything. Measured: a 10-byte slice of a 50 MB array leaves 50.1 MB on the heap; after a copy into its own array AND dropping the sub-slice, 0.1 MB — the copy alone frees nothing while the sub-slice is alive. The garbage collector works on whole arrays: while a single slice is alive, the whole array is alive. Which is why a function returning a piece it found in a large buffer has to return a copy.
A nil slice and an empty slice are the same thing
For len, cap, range, append and copy — yes, indistinguishable, which is why if s == nil is almost always a redundant check. But their pointers differ: nil has a zero one, []int{} does not (a shared zero object, with no memory allocated for it). The difference shows up in exactly one place, serialization: json.Marshal gives null against [], and a client expecting an array gets the wrong thing.
Ranging over a slice does not copy elements
It does, if you take the value. Measured on a slice of 136-byte structs: for _, r := range takes 32,028 ns, for j := range takes 6,206 — fivefold. Neither allocates: the copy goes on the stack. But 136 bytes are copied per iteration for the sake of the eight that are needed. On a []int the difference does not exist: there the element fits in a register anyway.
Check yourself
a := []int{1,2,3,4,5}. What is cap(a[1:3])?
Sources & further reading
8 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 full slice expression `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
- runtime/slice.go — growslice and nextslicecapGo source code. The threshold the whole growth argument turns on: `const threshold = 256`. The formula past the threshold is `newcap += (newcap + 3*threshold) >> 2`, with the authors' comment beside it: «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 result of the formula is then rounded by a call to `roundupsize` to an allocator size class, which is exactly why the observed ratios match neither 2 nor 1.25.https://go.dev/src/runtime/slice.go
- Commit “runtime: make slice growth formula a bit smoother”, Keith RandallSource. The change after which the folklore went stale: «Instead of growing 2x for < 1024 elements and 1.25x for >= 1024 elements, use a somewhat smoother formula for the growth factor. Start reducing the growth factor after 256 elements, but slowly». The message carries a table of expected factors: 256 → 2.0, 512 → 1.63, 1024 → 1.44, 2048 → 1.35, 4096 → 1.30. And a caveat that settles the accuracy of any such table: «(Note that the real growth factor, both before and now, is somewhat larger because we round up to the next size class.)»https://github.com/golang/go/commit/2dda92ff6f9f07eeb110ecbf0fc2d7a0ddd27f9d
- Go 1.18 Release Notes, Runtime sectionOfficial documentation. The one place that ties the change of formula to a release number: «The built-in function append now uses a slightly different formula when deciding how much to grow a slice when it must allocate a new underlying array. The new formula is less prone to sudden transitions in allocation behavior». What the formula is, the notes do not say — for that you have to go to the source.https://go.dev/doc/go1.18
- runtime/sizeclasses.go — the class_to_size tableGo source code. The set of block sizes the allocator is able to hand out at all: 8, 16, 24, 32, 48, 64, 80, … 2048, 2304, 2688, 3072. These are what `roundupsize` rounds a requested size up to, and they are why a slice of 40-byte structs ends up with a capacity of 67: the requested 64 × 40 = 2560 bytes land in class 2688, and 67 elements fit into it.https://go.dev/src/runtime/sizeclasses.go
- Go Slices: usage and internalsOfficial documentation. The official introduction, where a slice is described as a descriptor of an array segment made of three fields — a pointer, a length and a capacity — and where the memory trap is named: while a slice is alive the garbage collector cannot free the whole array beneath it, so a function returning a small piece of a large buffer has to return a copy.https://go.dev/blog/slices-intro
- slices/slices.go — Clip and CloneGo source code. Both bodies are shorter than their doc comments, and they are the whole answer: `func Clip[S ~[]E, E any](s S) S { return s[:len(s):len(s)] }` — the three-index expression and zero copies; `Clone` ends in `return append(S{}, s...)`, which copies every time. Its nil branch carries the authors' note, "Preserve nilness in case it matters", and the avoidance of `s[:0:0]` points at go.dev/issue/68488: that expression keeps a large array alive.https://go.dev/src/slices/slices.go
- Data Race DetectorOfficial documentation. Where the exit status 66 in the shared-array section comes from: "`exitcode` (default `66`): The exit status to use when exiting after a detected race." And the boundary a green run does not cross: "The race detector only finds races that happen at runtime, so it can't find races in code paths that are not executed."https://go.dev/doc/articles/race_detector