Slices
Arrays: the foundation
Section titled “Arrays: the foundation”An array in Go has a fixed size that is part of its type. [3]int and [4]int are two different types:
arr := [3]int{10, 20, 30}fmt.Println(len(arr)) // 3 — alwaysArrays are value types: assigning a2 := a1 copies every element. They are useful for fixed-size data but too rigid for most work.
You do not have to count the elements yourself — [...]T{...} asks the compiler to size the array from the literal:
a := [...]int{1, 2, 3, 4}fmt.Printf("%T %d\n", a, len(a)) // [4]int 4 — still a genuine array, size fixed at compile timeSlices: the dynamic view
Section titled “Slices: the dynamic view”A slice is a lightweight header — three words — that describes a window into an underlying array:
flowchart LR
subgraph Header["slice header"]
P["pointer"]
L["len = 3"]
C["cap = 5"]
end
subgraph Backing["backing array"]
A0["10"] --> A1["20"] --> A2["30"] --> A3["40"] --> A4["50"]
end
P --> A0 len— number of elements currently accessible through the slice.cap— number of elements from the slice’s start to the end of the backing array.
Create a slice with make, a composite literal, or by slicing an array:
s := make([]int, 3, 5) // len=3, cap=5, all zerost := []int{1, 2, 3} // len=3, cap=3u := arr[1:3] // len=2, cap=2, window into arrappend and growth
Section titled “append and growth”append adds elements to a slice and returns the (possibly new) slice:
s = append(s, 4, 5, 6)When len == cap, append allocates a new, larger backing array, copies existing elements, and returns a slice pointing to the new array. The old slice is unaffected. Always reassign the result of append — s = append(s, ...).
The growth policy
Section titled “The growth policy”When append needs a new backing array, Go does not pick a random size — it follows a growth policy so that appending n elements one at a time still costs O(n) total, not O(n²). On Go 1.26.5 the policy is: double the capacity while it is small (under 256), then grow by roughly 1.25×, and finally round up to one of the runtime’s fixed allocation size classes. That last rounding step is why the numbers are not exact powers of two or exact ×1.25 — the allocator only has certain bucket sizes available and always rounds up to the nearest one.
This is exactly what produces 6 10 in the playground below: make([]int, 3, 5) starts at cap 5; appending 3 more elements needs cap ≥ 6, so growth doubles 5 → 10 (still in the “double” regime).
Appending one element at a time and printing cap(s) only when it changes shows the policy directly (go run on 1.26.5):
s := make([]int, 0)prevCap := cap(s)for i := 0; i < 2100; i++ { s = append(s, i) if cap(s) != prevCap { fmt.Println(len(s), cap(s)) prevCap = cap(s) }}// 1 4// 5 8// 9 16// 17 32// 33 64// 65 128// 129 256// 257 512 <- still doubling exactly at the 256 boundary// 513 848 <- now ~1.25x + size-class rounding, not a clean double// 849 1280// 1281 1792// 1793 2560Never rely on the exact growth numbers in your own code — they are a runtime implementation detail and have changed across Go versions before. What you can rely on is the amortized O(1) cost per append. If you already know roughly how many elements you will store, pre-size with make([]T, 0, n) or grow explicitly with slices.Grow(s, n) (Go 1.21+) to skip the reallocation dance entirely — slices.Grow guarantees capacity for at least n more elements without touching len:
s := make([]int, 2, 2)s = slices.Grow(s, 10)fmt.Println(len(s), cap(s)) // 2 12 — len unchanged, cap rounded up by the same size classesSlicing expressions and shared backing arrays
Section titled “Slicing expressions and shared backing arrays”a[low:high] creates a new slice header that shares the same backing array:
base := []int{1, 2, 3, 4, 5}a := base[1:3] // [2 3] — shares backing array with baseb := base[1:4] // [2 3 4] — also sharesa[0] = 99fmt.Println(b[0]) // 99 — mutation visible through b tooThis is powerful (zero-copy sub-slicing) but dangerous if you forget about it.
Aliasing: append can overwrite a sibling slice’s data
Section titled “Aliasing: append can overwrite a sibling slice’s data”The dangerous case is not a[0] = 99 — that’s an obvious, visible mutation. The dangerous case is append, because it looks like it should only affect a, but if a still has spare capacity inside base’s backing array, append writes directly into memory that base still considers “its own”:
base := []int{1, 2, 3, 4, 5}a := base[1:3] // len=2, cap=4 — a has 2 elements of spare capacity inside basea = append(a, 99)fmt.Println(base) // [1 2 3 99 5] — base[3] silently overwritten, base never touched directlya had cap == 4 (from index 1 to the end of base), so appending one element fit inside the existing array — no reallocation, no warning, just a write into base[3]. This is one of the most common AI-generated-Go bugs: code that takes a sub-slice “as a snapshot” or “to keep the tail for later” and then appends to either the sub-slice or the original, silently corrupting the other.
The three-index slice expression base[low:high:max] fixes this by capping the new slice’s capacity at max, so it has zero spare room and append is forced to allocate a fresh backing array instead of reaching into base:
c := base[1:3:3] // len=2, cap=2 — no spare capacity, "full slice expression"c = append(c, 77)fmt.Println(base, c) // base unchanged; c now has its own independent backing arrayUse base[low:high:high] (capping max at high) any time you hand out a sub-slice and do not want the receiver’s append calls to ever reach back into your array.
Slices as function arguments
Section titled “Slices as function arguments”A slice header is copied by value into a function, just like everything else in Go — but the copy still points at the same backing array. That gives function-argument slices a split personality:
func appendElem(s []int) { s[0] = -1 // writes through the shared backing array — visible to the caller s = append(s, 999) // only reassigns this function's local copy of the header — invisible to the caller}
nums := []int{1, 2, 3}appendElem(nums)fmt.Println(nums) // [-1 2 3] — the element write stuck, the append did notElement writes (s[i] = x) are always visible to the caller because they go through the shared pointer. append is only visible to the caller if the function returns the new slice and the caller reassigns it — nums = appendElem(nums). An AI-generated helper that appends to a parameter and expects the caller to see the growth without returning anything is a compile-clean, silently-wrong bug.
copy for independent slices
Section titled “copy for independent slices”Use the built-in copy(dst, src) to break the sharing. It copies min(len(dst), len(src)) elements and returns the number copied:
fresh := []int{10, 20, 30}dst := make([]int, 3)copy(dst, fresh)dst[0] = 0fmt.Println(fresh[0], dst[0]) // 10 0 — independentslices.Clone(a) and append([]int(nil), a...) give you the same result as copy into a fresh slice — all three produce an independent backing array.
nil vs empty slices
Section titled “nil vs empty slices”A nil slice and an empty (zero-length, non-nil) slice both have len == 0, and most operations treat them identically — but they are not the same value, and code that assumes otherwise breaks in two places:
var n []int // nil slice: no backing array at alle := []int{} // empty slice: has a backing array, just zero elements
fmt.Println(n == nil, e == nil) // true falsefmt.Println(len(n), len(e)) // 0 0 — both report zero length== against nil is the only comparison a slice supports (two non-nil slices cannot be compared with == at all — that’s a compile error), and it is the one place nil and []int{} genuinely differ. The other place is JSON: encoding/json marshals a nil slice as null and an empty slice as []. If an API contract promises an array field is never null, initialize with make([]T, 0) or []T{}, not var s []T.
You almost never need to construct a nil slice on purpose — a var s []T zero value already is one, and it is perfectly safe to append to (append allocates on first use). Reserve []T{} for the cases where an empty-but-present array actually means something different to a caller.
Memory retention from sub-slicing a large buffer
Section titled “Memory retention from sub-slicing a large buffer”Because a sub-slice shares its backing array with the slice it came from, keeping even a tiny sub-slice alive keeps the entire original backing array alive — the garbage collector cannot reclaim any of it as long as one live slice header still points into it. This is easy to miss when the pattern is “read a big buffer, keep a small interesting piece of it”:
// bigBuf might be a 64 KB read buffer from a network connection or bufio.ReaderbigBuf := make([]byte, 65536)// ... fill bigBuf ...header := bigBuf[:16] // "just the first 16 bytes" — but header.cap reaches to the end of bigBufstore(header) // if store() keeps header around, all 65536 bytes stay aliveThe fix is the same tool used earlier to break aliasing: copy the interesting part into a freshly, correctly sized slice before discarding the reference to the big buffer, so only the small piece survives:
headerCopy := make([]byte, 16)copy(headerCopy, bigBuf[:16])store(headerCopy) // bigBuf can now be garbage-collected once it goes out of scopeWatch for this pattern anywhere a long-lived slice is derived from slicing (not copying) a large, short-lived buffer — bufio.Scanner.Bytes() documents exactly this hazard: the returned slice is only valid until the next Scan() call, precisely because it aliases the scanner’s internal buffer.
min, max, clear, and the slices package
Section titled “min, max, clear, and the slices package”Go 1.21 added three builtins and a generic slices package, so most hand-written helper loops are gone from modern code:
import "slices"
n := min(3, 7, 1) // 1 — works for any ordered typem := max(2.5, 1.0) // 2.5clear(s) // zero every element, keeps len and cap
xs := []int{3, 1, 2}slices.Sort(xs) // [1 2 3]slices.Contains(xs, 2) // truei, found := slices.BinarySearch(xs, 3) // 2, trueys := slices.Clone(xs) // independent copy, no make+copy neededxs = slices.Insert(xs, 0, 9)slices.Sort, slices.Index, slices.Reverse, slices.Compact and friends are type-safe and usually faster than the sort package. From Go 1.23, slices.Collect, slices.Sorted and slices.Values also bridge slices and iterators — see the Iterators lesson. Running go fix (Go 1.26+) can rewrite many old loops into these calls automatically.
package main
import "fmt"
func main() { // Array vs slice arr := [3]int{10, 20, 30} // fixed-size array sl := arr[:] // slice backed by arr sl[0] = 99 fmt.Println(arr[0], sl[0]) // 99 99 — shared backing array
// make: len and cap s := make([]int, 3, 5) fmt.Println(len(s), cap(s)) // 3 5
// append beyond cap allocates a new backing array s = append(s, 1, 2, 3) fmt.Println(len(s), cap(s)) // 6 10
// Two slices sharing the same backing array base := []int{1, 2, 3, 4, 5} a := base[1:3] // [2 3] b := base[1:4] // [2 3 4] a[0] = 99 fmt.Println(b[0]) // 99 — shared backing array
// copy to get an independent slice fresh := []int{10, 20, 30} dst := make([]int, 3) copy(dst, fresh) dst[0] = 0 fmt.Println(fresh[0], dst[0]) // 10 0 — independent after copy} The next playground focuses purely on the depth topics above: aliasing through append, the three-index slice expression, slices as function arguments, nil vs empty, and range copying elements.
package main
import "fmt"
// appendElem writes through the shared backing array, but growing via append// only updates this function's local copy of the slice header.func appendElem(s []int) { s[0] = -1 // visible to the caller s = append(s, 999) // invisible to the caller}
func main() { // Aliasing: append can silently overwrite a sibling slice's data base := []int{1, 2, 3, 4, 5} a := base[1:3] // len=2 cap=4 -- spare capacity inside base a = append(a, 99) fmt.Println(base) // [1 2 3 99 5] -- base[3] overwritten
// The three-index slice expression removes the spare capacity c := base[1:3:3] // len=2 cap=2 -- no room left c = append(c, 77) fmt.Println(base, c) // base untouched, c has its own backing array
// Slices as function arguments nums := []int{1, 2, 3} appendElem(nums) fmt.Println(nums) // [-1 2 3]
// nil vs empty var n []int e := []int{} fmt.Println(n == nil, e == nil, len(n), len(e)) // true false 0 0
// range copies each element into v vals := []int{1, 2, 3} for _, v := range vals { v *= 10 } fmt.Println(vals) // [1 2 3] -- unchanged} Mental Model
Section titled “Mental Model”flowchart TD
Header["slice header (3 words, 24 bytes): pointer, len=3, cap=5"] --> Backing["backing array (cap=5): [0][1][2][3][4], visible range = len (3)"]
subgraph WithinCap["append within cap"]
A1["s = append(s, 4)"] --> A2["len: 4, cap: 5 (no new allocation)"]
end
subgraph PastCap["append past cap"]
B1["s = append(s, 5, 6)"] --> B2["Go allocates a new backing array (~2x cap)"]
B2 --> B3["old backing array stays alive if another slice still points at it"]
end
Backing --> WithinCap
Backing --> PastCap Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| slice header is a value (24 bytes) | cheap to pass to a function | mutation through a copied slice still hits the original backing array |
| dynamic append | grows automatically | reallocation past cap — an O(n) copy |
slicing [a:b] | zero-copy subview | shared backing array — can prevent GC of a large array |
make([]T, len, cap) | pre-allocate to avoid realloc | you have to estimate cap up front |
Common Misconceptions
Section titled “Common Misconceptions”- A slice and an array are the same thing — a slice is a (pointer, len, cap) header pointing at an array; an array is a fixed-size value type.
appendis always safe and mutates in place —appendmay return a slice pointing at a brand-new backing array — always assign the result back.lenandcapmean the same thing —lenis how many elements are reachable right now;capis the maximum size before reallocation.copy(dst, src)does a deep copy —copytransfers values, but if an element is a pointer or interface it still shares the same underlying data.
Reading AI-generated code
Section titled “Reading AI-generated code”When you read a slice-heavy function an AI wrote (or wrote yourself quickly), check these before trusting it:
- Does it slice a parameter or a struct field (
x[a:b]) and then hand the result to something long-lived? That sub-slice keeps the entire original backing array alive, and if the original is later mutated or appended-to, the sub-slice can see writes it should not. - Does it
appendto a slice that was created by slicing another slice (sub := big[i:j]), without a three-index expression? Ifsubstill has spare capacity insidebig, thatappendcan silently corruptbig’s data past indexj. - Does a function
appendto a slice parameter and expect the caller to see the new elements without the function returning the slice? Only element writes (s[i] = x) are guaranteed visible to the caller — growth is not. - Does it compare a slice to
nilwhen it actually meant “is this empty,” or vice versa (e.g. relying on JSONnullvs[]without checking which one the code path produces)? - Does a loop do
for _, v := range s { v = ... }and expectsto change?vis a copy; onlys[i] = ...(orfor i := range s) mutates the original.
🐛 Spot the bug
func dedupe(nums []int) []int { out := nums[:0] for i, n := range nums { if i == 0 || n != nums[i-1] { out = append(out, n) } } return out}
func main() { data := []int{1, 1, 2, 3, 3} backup := data[2:] // keep the tail "just in case" result := dedupe(data) fmt.Println(result, backup)}Show the answer
out := nums[:0] reuses nums’s own backing array as the destination — a common, usually-correct in-place dedupe trick. But backup := data[2:] aliases that same array, starting at index 2. As dedupe writes deduplicated values forward (1, 2, 3 land at indices 0, 1, 2), it overwrites index 2 — which is exactly where backup starts. Verified output on 1.26.5: [1 2 3] [3 3 3] — backup was silently corrupted by dedupe’s in-place writes, even though dedupe never touched a variable named backup. The fix: either don’t write in place (out := make([]int, 0, len(nums))), or take backup as an independent copy (backup := slices.Clone(data[2:])) before calling anything that might mutate data’s backing array in place.