Slices in Go
A slice is a dynamic, flexible view into an underlying array. It’s one of the most used data structures in Go.
📐 Analogy: Think of a slice like a window on a long paper roll. The window (slice) shows only a portion — but the roll (array) is still there behind it.
1. Internal Structure 🔥
Every slice is a small 3-field struct under the hood:
type slice struct {
ptr *T // pointer to the first element in the underlying array
len int // number of accessible elements
cap int // total elements available from ptr to end of array
}s := make([]int, 3, 5)
// ptr → [0, 0, 0, _, _] (underlying array of 5)
// len = 3 (you can access s[0], s[1], s[2])
// cap = 5 (room for 2 more before reallocation)2. Declaration & Initialization
var s []int // nil slice — len=0, cap=0, s==nil
s := []int{} // empty slice — len=0, cap=0, s!=nil
s := []int{1, 2, 3} // literal — len=3, cap=3
s := make([]int, 3) // len=3, cap=3
s := make([]int, 3, 10) // len=3, cap=10 — pre-allocated| Form | nil? | Use when |
|---|---|---|
var s []int | ✅ | Default zero value, unknown size |
[]int{} | ❌ | Need non-nil empty slice (e.g. JSON []) |
make([]T, n) | ❌ | Known length |
make([]T, 0, n) | ❌ | Known capacity, will append |
💡 For JSON:
var s []intmarshals asnull;[]int{}marshals as[]. Use the right one!
3. len vs cap — Slicing Expressions
s := []int{10, 20, 30, 40, 50}
// 0 1 2 3 4
t := s[1:3] // [20, 30]| Expression | Result | len | cap |
|---|---|---|---|
s[1:3] | [20, 30] | 2 | 4 (from index 1 to end) |
s[:3] | [10, 20, 30] | 3 | 5 |
s[2:] | [30, 40, 50] | 3 | 3 |
s[:] | [10, 20, 30, 40, 50] | 5 | 5 |
Rule:
cap = original_cap - low_index. The window starts atlowand the capacity stretches to the end of the original array.
4. Capacity Growth — How append Grows 🔥
When you append beyond capacity, Go allocates a new, larger array and copies everything over. The old array is abandoned.
Observed Growth (Go 1.21, []int)
s := []int{}
for i := 0; i < 17; i++ {
s = append(s, i)
fmt.Printf("len=%-3d cap=%d\n", len(s), cap(s))
}| Elements Added | len | cap | Event |
|---|---|---|---|
| 1 | 1 | 1 | Initial allocation |
| 2 | 2 | 2 | Grew ×2 |
| 3 | 3 | 4 | Grew ×2 |
| 4 | 4 | 4 | Same capacity |
| 5 | 5 | 8 | Grew ×2 |
| 6–8 | 6–8 | 8 | Same capacity |
| 9 | 9 | 16 | Grew ×2 |
| 10–16 | 10–16 | 16 | Same capacity |
| 17 | 17 | 32 | Grew ×2 |
Growth rule (simplified): ~2× for small slices. Transitions to ~1.25× growth for large slices (after ~256 elements). Exact factor is not guaranteed — never rely on a specific capacity value.
▶ Run this to observe growth live (tracks len, cap, growth ratio & reallocation)
package main
import "fmt"
func main() {
s := []int{}
prevCap := cap(s)
var prevPtr *int
fmt.Printf("%-5s %-5s %-8s %-12s %-12s\n", "len", "cap", "growth", "ptr", "event")
for i := 0; i < 1500; i++ {
s = append(s, i)
var currPtr *int
if len(s) > 0 {
currPtr = &s[0] // address of underlying array
}
event := ""
growth := ""
if cap(s) != prevCap {
if prevCap == 0 {
event = "init"
} else {
event = "grow"
growth = fmt.Sprintf("%.2f", float64(cap(s))/float64(prevCap))
}
}
// detect reallocation (pointer change)
if prevPtr != nil && currPtr != prevPtr {
event += "+realloc"
}
fmt.Printf("%-5d %-5d %-8s %-12p %-12s\n",
len(s), cap(s), growth, currPtr, event)
prevCap = cap(s)
prevPtr = currPtr
}
}What this proves:
&s[0]points to the underlying array- When capacity grows → pointer changes = new array allocated
- Same pointer = same backing array, no copy happened
len cap growth ptr event
1 1 0x1400012a0 init
2 2 2.00 0x1400012c0 grow+realloc
3 4 2.00 0x140001300 grow+realloc
4 4 0x140001300
5 8 2.00 0x140001380 grow+realloc🎯 Interview line: “You can verify slice reallocation by comparing
&s[0]. When capacity grows, the pointer changes — confirming a new backing array was allocated.”
Why this matters
// ❌ Slow — O(n) amortized copies total, but triggers multiple reallocations
s := []int{}
for i := 0; i < 10000; i++ {
s = append(s, i)
}
// ✅ Fast — one allocation, no reallocation
s := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
s = append(s, i)
}5. Shared Memory — The Trap ⚠️
Sub-slices share the same underlying array. Modifying one affects the other — until append causes a reallocation.
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // b = [2, 3], shares a's array
b[0] = 99
fmt.Println(a) // [1, 99, 3, 4, 5] — a was modified!// ✅ Full slice expression — limit b's capacity to prevent overflow writes
b := a[1:3:3] // cap of b = 3-1 = 2; append will allocate new array// ✅ Independent copy — breaks the link
b := make([]int, len(a[1:3]))
copy(b, a[1:3])6. Common Operations
s := []int{1, 2, 3, 4, 5}
// Delete element at index i (order preserved)
i := 2
s = append(s[:i], s[i+1:]...) // [1, 2, 4, 5]
// Delete (order NOT preserved — faster)
s[i] = s[len(s)-1]
s = s[:len(s)-1]
// Insert at index i
s = append(s[:i+1], s[i:]...)
s[i] = 99
// Reverse
for l, r := 0, len(s)-1; l < r; l, r = l+1, r-1 {
s[l], s[r] = s[r], s[l]
}
// Clear — two different behaviours (Go 1.21+)
s = s[:0] // len=0, cap unchanged, old values still in memory (not zeroed)
clear(s) // zeroes all elements, len & cap unchanged — use when data is sensitive7. slices Package — Modern Go (1.21+)
Go 1.21 added the slices package. These replace verbose manual loops and are now idiomatic.
import "slices"| Function | What it does | Since |
|---|---|---|
slices.Contains(s, v) | checks if v exists in s | 1.21 |
slices.Index(s, v) | returns first index of v, or -1 | 1.21 |
slices.Reverse(s) | reverses s in-place | 1.21 |
slices.Sort(s) | sorts ordered types in-place | 1.21 |
slices.SortFunc(s, cmp) | sorts with custom comparator | 1.21 |
slices.Equal(a, b) | deep equality check | 1.21 |
slices.Delete(s, i, j) | removes s[i:j] cleanly | 1.21 |
slices.Insert(s, i, v...) | inserts values at index i | 1.21 |
slices.Clip(s) | removes unused capacity (s[:len:len]) | 1.21 |
slices.Compact(s) | removes consecutive duplicates | 1.21 |
slices.Max(s) / slices.Min(s) | max / min element | 1.21 |
slices.Concat(a, b, ...) | concatenates multiple slices | 1.22 |
slices.Repeat(s, n) | repeats slice n times | 1.23 |
s := []int{3, 1, 4, 1, 5, 9, 2, 6}
slices.Sort(s)
fmt.Println(s) // [1 1 2 3 4 5 6 9]
fmt.Println(slices.Contains(s, 5)) // true
fmt.Println(slices.Index(s, 5)) // 5
slices.Reverse(s)
fmt.Println(s) // [9 6 5 4 3 2 1 1]
fmt.Println(slices.Max(s)) // 9
fmt.Println(slices.Min(s)) // 1
// slices.Delete — cleaner than append trick
s = slices.Delete(s, 2, 4) // removes s[2:4]
// slices.Insert — cleaner than manual shift
s = slices.Insert(s, 1, 99, 100) // inserts 99, 100 at index 1
// slices.Equal — replaces reflect.DeepEqual for slices
fmt.Println(slices.Equal([]int{1, 2}, []int{1, 2})) // true⚠️ Interview note:
slices.Deletedoes not preserve order by default and may leave stale data at the tail. If you need zero-safe delete useslices.Delete+clearon the tail.
8. Array vs Slice
| Feature | Array | Slice |
|---|---|---|
| Size | Fixed at compile time | Dynamic |
| Type | Value type — full copy on assign | Reference type — header copy only |
| Comparable | ✅ == works | ❌ only nil comparison |
| Pass to function | Entire array copied | Only 3-field header copied |
| Common use | Rare (fixed buffers) | Very common |
9. Common Mistakes ⚠️
| Mistake | Problem | Fix |
|---|---|---|
| Write to nil slice | runtime panic | make or literal first |
| Sub-slice mutation | modifies parent unexpectedly | copy() for independence |
| Large array kept alive by small sub-slice | memory leak | copy() into new slice |
append result not assigned | original unchanged | always s = append(s, ...) |
Assume cap after append | non-deterministic | never rely on exact cap value |
// ❌ Memory leak — 1,000,000 ints kept alive for just 10 values
big := make([]int, 1_000_000)
small := big[:10] // holds reference to entire backing array
// ✅ Break the reference
small := make([]int, 10)
copy(small, big[:10])10. Interview Cheat Sheet
Q: What are the three fields of a slice?
ptr(pointer to array),len(accessible elements),cap(total elements from ptr to end of array).
Q: What happens when append exceeds capacity?
Go allocates a new, larger array (roughly 2× for small slices), copies all elements, and returns a new slice header. The original variable is unaffected unless you reassign it.
Q: Are slices passed by value or reference?
By value — but the value is a 3-field header containing a pointer. So element modifications inside a function affect the original, but reassigning the slice (
s = append(...)) does not.
Q: Difference between nil slice and empty slice?
Both have
len=0. Nil slice (var s []int) hass == niland marshals to JSONnull. Empty slice ([]int{}) hass != niland marshals to[].
Q: When does sub-slicing cause bugs?
When two slices share the same backing array — writes to one affect the other. Use
copy()or the 3-index slicea[low:high:max]to prevent this.
Q: How do you pre-allocate for performance?
make([]T, 0, n)— sets capacity tonupfront soappendnever needs to reallocate during the filling loop.