Deep Engineering
Intermediate·Published·35 MIN

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.

Where to start
Before this lesson it is enough to understand
  • 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;
  • append adds an element at the end, and the result is assigned back to the same variable.
You do not need to know in advance
  • what a slice is made of: a pointer, a length, a capacity, cap, the three-index form s[a:b:c];
  • growslice, nextslicecap, allocator size classes, amortized analysis;
  • slices.Clone, the difference between a nil slice 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:

  1. "What is a slice?" — testing whether you know about the header. "A dynamic array" closes this question and opens the next one.
  2. "What does append do?" — testing whether you know there are TWO cases and that they differ by whether there is spare capacity.
  3. "Why did the neighbouring slice change?" (with code on paper) — testing the first two together. This is the central question of the topic.
  4. "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:

GO
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] = 99

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

language contractA language guarantee: it will not change with the Go version or the machine.

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

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]int instead of [1000]int does make sense — there you would be copying eight kilobytes.
  • A function can change your data while returning nothing: s[0] = 1 inside 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:

  1. computes the new capacity — that is nextslicecap, dissected under "Deeper";
  2. converts it to bytes and rounds up to an allocator size class;
  3. allocates a new array;
  4. copies the len elements 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:

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

measured observationbench/goslice/internals.go, go1.24.7 linux/amd64, two cores. Slice setup time is subtracted from each row; the absolute nanoseconds depend on the machine, what carries meaning is the series.
what we doelements shiftedtime
delete the first9,9991,401 ns
delete the middle4,999667 ns
delete the second-to-last1below resolution
shorten from the end, s = s[:len(s)-1]0below 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:

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

GO
live := []*Node{a, b, c}
live = live[:2]      // c is no longer visible... but the array holds it

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

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

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

language contractA language guarantee: nothing to check, it follows from the semantics of the range statement.

v is a copy of the element, and writing to it does not change the slice.

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

measured observationbench/goslice/internals.go, a slice of 10,000 structs of 64 bytes, go1.24.7 linux/amd64, two cores.

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

GO
var a []int      // nil
b := []int{}     // not nil

Both 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 == niltruefalse
json.Marshalnull[]

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.

implementation detail · Go 1.24The capacity growth formula is not in the specification and has changed between versions. Below is what the current runtime does; code must never rely on a specific cap.

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:

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

nreallocationscopiedtimes n
1,000121,8711.87
100,00028402,0794.02
1,000,000384,154,0154.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

Next they ask

If append can write into the same array, why does it return a slice at all?

Short answer

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.

Next they ask

Then why not always grow by doubling — surely that copies less?

Short answer

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

Next they ask

Is copy really faster than append in a loop?

Short answer

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.

Next they ask

What happens if you append to a slice inside a loop over that same slice?

Short answer

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.

Next they ask

Why does cap sometimes come out as a number nobody asked for?

Short answer

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.

Next they ask

Is a slice a reference type?

Short answer

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

Claim

a slice is a dynamic array

Actually

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.

Claim

append always allocates a new array, so the original data is safe

Actually

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

Claim

capacity doubles up to 1024 and then grows by a quarter

Actually

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.

Claim

amortized O(1) means append is always cheap

Actually

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.

Claim

deleting from a slice is just an append, and costs the same everywhere

Actually

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.

Claim

range by value is slower because it copies the element

Actually

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.

Claim

returning a small piece of a large slice releases the memory

Actually

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.

Claim

a nil slice and an empty slice are the same thing

Actually

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

A slice is taken from the middle of another, and an element is appended to it. What does this code print?
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

A hundred thousand elements through append. How many times faster is it when the capacity is set up front with make(…, 0, N)?
times

Knowledge check

Question 1 of 6

A function takes a slice and does s[0] = 42, returning nothing. Does the caller see the change?

Sources & further reading

3 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 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
  2. 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
  3. 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