Deep Engineering
Advanced·Published·30 MIN

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: 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 append inside a function is invisible outside it, while a write to an element is visible.
  • append into a sub-slice can clobber a neighbour. a[:2] has length 2 but capacity all the way to the end of the array; append sees 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, and append has 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 cap sometimes 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.

The Go specification, Slice types

A descriptor is literally a struct of three fields, and you can ask the compiler for its size:

GO
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 elements

An 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 Go specification, Slice types

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.

GO
func grow(s []int) { s = append(s, 42) }   // invisible outside
func set(s []int)  { s[0] = 7 }            // visible outside

The 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

The Go specification, Slice expressions

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 Go specification, Appending to and copying slices

The word “otherwise” is the whole story:

GO
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 clobbered

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

GO
c := []int{1, 2}
d := append(c, 9)   // no capacity -> a new array, c untouched

The 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

The Go specification, Full slice expressions
GO
b := a[:2:2]   // length 2, capacity 2
b = append(b, 9)
// a = [1 2 3 4 5]  — untouched

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

GO
// 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] and buf[2:6] — the ranges overlap, a[2] and b[0] are one and the same cell;
  • two slices of len=1 cap=8 over one array — both appends write to buf[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.

Data Race Detector

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.

Ibid.

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:

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

Keith Randall, “runtime: make slice growth formula a bit smoother”

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

The same commit

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:

  • copy against a hand-written loop — 1,668 ns against 6,163 on the same 10,000 elements. copy and append(dst[:0], src...) come down to the same memmove, while the loop copies eight bytes at a time with a bounds check.
  • range by value against range by 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 []int this 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

GO
var n []int      // len 0, cap 0, n == nil,  pointer 0x0
e := []int{}     // len 0, cap 0, e != nil,  pointer non-zero

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

GO
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 null or [] 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.

Common misconceptions

Claim

A slice doubles up to 1024 elements, then grows by 1.25×

Actually

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.

Claim

The capacity of a[1:3] is 2 — the number of elements in the slice

Actually

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.

Claim

append always returns a new array, so the original slice is safe

Actually

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.

Claim

The full slice expression a[:2:2] limits the length

Actually

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.

Claim

make([]T, N) with index writes is faster than make([]T, 0, N) with append

Actually

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.

Claim

A slice of ten elements occupies memory for ten elements

Actually

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.

Claim

A nil slice and an empty slice are the same thing

Actually

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.

Claim

Ranging over a slice does not copy elements

Actually

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

Question 1 of 5

a := []int{1,2,3,4,5}. What is cap(a[1:3])?

Sources & further reading

8 SOURCES

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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