Complete Go Slice Guide | Internal Structure, Memory & Performance Optimization Deep Dive
Key Takeaways
Complete deep dive guide covering Go slice internals, memory allocation, and performance optimization. Includes pitfalls and solutions encountered in production.
Real-World Experience: Sharing memory leak and performance degradation experiences from not understanding slice internals, and optimization techniques learned while solving them.
Introduction: “Why Do Slices Behave This Way?”
Real-World Problem Scenarios
Scenario 1: Unexpected Memory Usage
Copied a slice but memory didn’t double. Why?
Scenario 2: Strange Behavior After append
Appended to slice and original was modified. What happened?
Scenario 3: Performance Degradation
Appending in loop makes program slow. How to optimize?
Table of Contents
- Slice Internal Structure
- Memory Allocation Mechanism
- append Operation Principles
- Slice Copy vs Reference
- Performance Optimization
- Practical Patterns
- Pitfalls and Solutions
- Advanced Techniques
1. Slice Internal Structure
What is a Slice?
A slice is Go’s dynamic array. Internally, it’s a struct with 3 fields:
type slice struct {
ptr unsafe.Pointer // Array pointer
len int // Current length
cap int // Capacity
}
Visualization
flowchart TB
subgraph Slice["Slice Struct"]
PTR["ptr: 0x1000"]
LEN["len: 3"]
CAP["cap: 5"]
end
subgraph Array["Actual Array (Memory)"]
A0["[0]: 10"]
A1["[1]: 20"]
A2["[2]: 30"]
A3["[3]: 0"]
A4["[4]: 0"]
end
PTR --> A0
Actual Memory Layout
package main
import (
"fmt"
"unsafe"
)
func main() {
s := []int{10, 20, 30}
// Slice header size
fmt.Printf("Slice header size: %d bytes\n", unsafe.Sizeof(s))
// 24 bytes (pointer 8 + len 8 + cap 8)
// Actual data size
fmt.Printf("Data size: %d bytes\n", len(s)*int(unsafe.Sizeof(s[0])))
// 24 bytes (int 8 * 3)
// Pointer address
fmt.Printf("Pointer: %p\n", &s[0])
fmt.Printf("Length: %d\n", len(s))
fmt.Printf("Capacity: %d\n", cap(s))
}
Output:
Slice header size: 24 bytes
Data size: 24 bytes
Pointer: 0xc000018030
Length: 3
Capacity: 3
2. Memory Allocation Mechanism
Creating Slices with make
// Method 1: Specify len only
s1 := make([]int, 5)
// len: 5, cap: 5
// [0, 0, 0, 0, 0]
// Method 2: Specify len and cap
s2 := make([]int, 3, 10)
// len: 3, cap: 10
// [0, 0, 0] + 7 reserved spaces
// Method 3: Literal
s3 := []int{1, 2, 3}
// len: 3, cap: 3
nil Slice vs Empty Slice
package main
import "fmt"
func main() {
// nil slice
var s1 []int
fmt.Printf("s1: %v, len: %d, cap: %d, nil: %v\n",
s1, len(s1), cap(s1), s1 == nil)
// s1: [], len: 0, cap: 0, nil: true
// Empty slice
s2 := []int{}
fmt.Printf("s2: %v, len: %d, cap: %d, nil: %v\n",
s2, len(s2), cap(s2), s2 == nil)
// s2: [], len: 0, cap: 0, nil: false
s3 := make([]int, 0)
fmt.Printf("s3: %v, len: %d, cap: %d, nil: %v\n",
s3, len(s3), cap(s3), s3 == nil)
// s3: [], len: 0, cap: 0, nil: false
}
Difference:
- nil slice: Pointer is nil, no memory allocation
- Empty slice: Valid pointer, memory allocated (small size)
Production Recommendation: Be careful with JSON encoding - [] vs null difference
3. append Operation Principles
Basic append
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
fmt.Printf("Before: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), &s[0])
s = append(s, 4)
fmt.Printf("After: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), &s[0])
}
Output:
Before: len=3, cap=3, ptr=0xc000018030
After: len=4, cap=6, ptr=0xc000018060
Note: Pointer changed → New array allocated
Capacity Growth Algorithm
Go allocates a new array and copies existing data when capacity is insufficient.
Growth Rules (Go 1.18+):
- cap < 256: 2x growth
- cap >= 256: 1.25x growth (gradual)
package main
import "fmt"
func main() {
s := make([]int, 0, 1)
for i := 0; i < 10; i++ {
oldCap := cap(s)
s = append(s, i)
newCap := cap(s)
if oldCap != newCap {
fmt.Printf("len=%d, cap: %d -> %d\n", len(s), oldCap, newCap)
}
}
}
Output:
len=2, cap: 1 -> 2
len=3, cap: 2 -> 4
len=5, cap: 4 -> 8
len=9, cap: 8 -> 16
append Performance Analysis
package main
import (
"fmt"
"time"
)
func benchmarkAppend(n int) time.Duration {
start := time.Now()
s := []int{}
for i := 0; i < n; i++ {
s = append(s, i)
}
return time.Since(start)
}
func benchmarkPrealloc(n int) time.Duration {
start := time.Now()
s := make([]int, 0, n) // Pre-allocation
for i := 0; i < n; i++ {
s = append(s, i)
}
return time.Since(start)
}
func main() {
n := 1000000
t1 := benchmarkAppend(n)
t2 := benchmarkPrealloc(n)
fmt.Printf("append: %v\n", t1)
fmt.Printf("prealloc: %v\n", t2)
fmt.Printf("improvement: %.2fx\n", float64(t1)/float64(t2))
}
Result:
append: 15.2ms
prealloc: 3.8ms
improvement: 4.00x
Conclusion: 4x performance improvement with pre-allocation
4. Slice Copy vs Reference
Is Slice a Reference Type?
Important: Slices are value types, but share internal pointers.
package main
import "fmt"
func main() {
s1 := []int{1, 2, 3}
s2 := s1 // Copy slice header
fmt.Printf("s1: %p, s2: %p\n", &s1, &s2)
// Different addresses (separate headers)
fmt.Printf("s1[0]: %p, s2[0]: %p\n", &s1[0], &s2[0])
// Same address (shared array)
s2[0] = 999
fmt.Println("s1:", s1) // [999, 2, 3]
fmt.Println("s2:", s2) // [999, 2, 3]
}
append and References
package main
import "fmt"
func main() {
s1 := []int{1, 2, 3}
s2 := s1
// Case 1: Sufficient capacity (no reallocation)
s1 = append(s1[:2], 999) // len=3, cap=3 → no reallocation
fmt.Println("s1:", s1) // [1, 2, 999]
fmt.Println("s2:", s2) // [1, 2, 999] ← s2 affected!
// Case 2: Insufficient capacity (reallocation)
s3 := []int{1, 2, 3}
s4 := s3
s3 = append(s3, 4) // cap exceeded → new array
s3[0] = 999
fmt.Println("s3:", s3) // [999, 2, 3, 4]
fmt.Println("s4:", s4) // [1, 2, 3] ← s4 unaffected
}
Deep Copy
package main
import "fmt"
func main() {
s1 := []int{1, 2, 3, 4, 5}
// Method 1: Use copy
s2 := make([]int, len(s1))
copy(s2, s1)
// Method 2: Use append
s3 := append([]int(nil), s1...)
// Method 3: Manual copy
s4 := make([]int, len(s1))
for i, v := range s1 {
s4[i] = v
}
s1[0] = 999
fmt.Println("s1:", s1) // [999, 2, 3, 4, 5]
fmt.Println("s2:", s2) // [1, 2, 3, 4, 5]
fmt.Println("s3:", s3) // [1, 2, 3, 4, 5]
fmt.Println("s4:", s4) // [1, 2, 3, 4, 5]
}
5. Performance Optimization
1) Pre-allocation
package main
import (
"fmt"
"time"
)
func withoutPrealloc(n int) time.Duration {
start := time.Now()
var s []int
for i := 0; i < n; i++ {
s = append(s, i)
}
return time.Since(start)
}
func withPrealloc(n int) time.Duration {
start := time.Now()
s := make([]int, 0, n)
for i := 0; i < n; i++ {
s = append(s, i)
}
return time.Since(start)
}
func main() {
n := 1000000
t1 := withoutPrealloc(n)
t2 := withPrealloc(n)
fmt.Printf("Without prealloc: %v\n", t1)
fmt.Printf("With prealloc: %v\n", t2)
fmt.Printf("Improvement: %.2fx\n", float64(t1)/float64(t2))
}
Result:
Without prealloc: 18.5ms
With prealloc: 4.2ms
Improvement: 4.40x
2) Slicing Optimization
package main
import "fmt"
func main() {
data := []byte("Hello, World! This is a long string.")
// ❌ Memory leak risk
// Need small part but keep entire array
small := data[0:5] // "Hello"
fmt.Println(string(small))
// small is small but references entire data
// ✅ Copy to allow memory release
small2 := make([]byte, 5)
copy(small2, data[0:5])
// Now data can be GC'd
// ✅ Go 1.21+: 3-index slice
small3 := data[0:5:5] // [low:high:max]
// Limit cap to 5 → new array on append
}
6. Practical Patterns
Pattern 1: Filtering
package main
import "fmt"
func filter(s []int, predicate func(int) bool) []int {
result := make([]int, 0, len(s)) // Pre-allocate
for _, v := range s {
if predicate(v) {
result = append(result, v)
}
}
return result
}
func main() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
evens := filter(numbers, func(n int) bool {
return n%2 == 0
})
fmt.Println("Evens:", evens) // [2, 4, 6, 8, 10]
}
Pattern 2: Map Transformation
package main
import "fmt"
func mapSlice[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
func main() {
numbers := []int{1, 2, 3, 4, 5}
squared := mapSlice(numbers, func(n int) int {
return n * n
})
fmt.Println("Squared:", squared) // [1, 4, 9, 16, 25]
strings := mapSlice(numbers, func(n int) string {
return fmt.Sprintf("num-%d", n)
})
fmt.Println("Strings:", strings) // [num-1, num-2, ...]
}
Pattern 3: Reduce
package main
import "fmt"
func reduce[T, U any](s []T, initial U, f func(U, T) U) U {
result := initial
for _, v := range s {
result = f(result, v)
}
return result
}
func main() {
numbers := []int{1, 2, 3, 4, 5}
sum := reduce(numbers, 0, func(acc, n int) int {
return acc + n
})
fmt.Println("Sum:", sum) // 15
product := reduce(numbers, 1, func(acc, n int) int {
return acc * n
})
fmt.Println("Product:", product) // 120
}
7. Pitfalls and Solutions
Pitfall 1: Memory Leak After Slicing
package main
import (
"fmt"
"runtime"
)
func printMemory() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc: %d MB\n", m.Alloc/1024/1024)
}
func main() {
// Create large slice
data := make([]byte, 100*1024*1024) // 100MB
for i := range data {
data[i] = byte(i)
}
printMemory() // Alloc: 100 MB
// ❌ Use small part but keep entire array
small := data[0:10]
data = nil // data is nil but
runtime.GC()
printMemory() // Alloc: 100 MB (still high)
// small still references entire array
fmt.Println(len(small))
// ✅ Fix with copy
small2 := make([]byte, 10)
copy(small2, data[0:10])
data = nil
small = nil
runtime.GC()
printMemory() // Alloc: ~0 MB
}
Pitfall 2: Slice Pointers in Loops
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
// ❌ Wrong pattern
var ptrs []*int
for _, v := range s {
ptrs = append(ptrs, &v) // v address is always same
}
for _, ptr := range ptrs {
fmt.Print(*ptr, " ") // 3 3 3
}
fmt.Println()
// ✅ Correct pattern
var ptrs2 []*int
for i := range s {
ptrs2 = append(ptrs2, &s[i])
}
for _, ptr := range ptrs2 {
fmt.Print(*ptr, " ") // 1 2 3
}
fmt.Println()
}
Pitfall 3: Function Arguments
package main
import "fmt"
func modifySlice(s []int) {
s[0] = 999 // Modifies original
s = append(s, 100) // New array allocated (separated from original)
}
func main() {
s := []int{1, 2, 3}
modifySlice(s)
fmt.Println(s) // [999, 2, 3] ← only s[0] changed
// append doesn't affect original
}
Solution: Pass as pointer
func modifySlicePtr(s *[]int) {
(*s)[0] = 999
*s = append(*s, 100)
}
func main() {
s := []int{1, 2, 3}
modifySlicePtr(&s)
fmt.Println(s) // [999, 2, 3, 100]
}
8. Advanced Techniques
1) Slice Pooling
package main
import (
"fmt"
"sync"
)
var bufferPool = sync.Pool{
New: func() interface{} {
return make([]byte, 0, 1024)
},
}
func processData(data []byte) {
// Get buffer
buf := bufferPool.Get().([]byte)
buf = buf[:0] // Reset length
// Use
buf = append(buf, data...)
fmt.Printf("Processed: %d bytes\n", len(buf))
// Return
bufferPool.Put(buf)
}
func main() {
for i := 0; i < 5; i++ {
data := []byte(fmt.Sprintf("Data %d", i))
processData(data)
}
}
2) Efficient Insert/Delete
package main
import "fmt"
// Insert at middle
func insert[T any](s []T, index int, value T) []T {
s = append(s[:index], append([]T{value}, s[index:]...)...)
return s
}
// Delete at middle
func remove[T any](s []T, index int) []T {
return append(s[:index], s[index+1:]...)
}
// Efficient delete (order doesn't matter)
func removeFast[T any](s []T, index int) []T {
s[index] = s[len(s)-1] // Overwrite with last element
return s[:len(s)-1]
}
func main() {
s := []int{1, 2, 3, 4, 5}
s = insert(s, 2, 99)
fmt.Println("Insert:", s) // [1, 2, 99, 3, 4, 5]
s = remove(s, 2)
fmt.Println("Remove:", s) // [1, 2, 3, 4, 5]
s = removeFast(s, 2)
fmt.Println("Fast remove:", s) // [1, 2, 5, 4]
}
Summary
Key Takeaways
-
Internal Structure
- Slice is (ptr, len, cap) struct
- Actual data stored in separate array
- Slice copy only copies header (shares array)
-
append Operation
- Sufficient cap: Use existing array
- Insufficient cap: Allocate new array + copy
- Growth rule: cap < 256 → 2x, cap >= 256 → 1.25x
-
Performance Optimization
- Pre-allocation:
make([]T, 0, n) - Index assignment > append (when size is fixed)
- Slice pooling (reuse)
- Pre-allocation:
-
Cautions
- Watch for memory leaks after slicing
- Don’t reference loop variable addresses
- Be careful with append when passing to functions
Best Practices
// ✅ Pre-allocate when size is known
s := make([]int, 0, expectedSize)
// ✅ Use index assignment for fixed size
s := make([]int, n)
for i := 0; i < n; i++ {
s[i] = value
}
// ✅ Deep copy when needed
s2 := make([]T, len(s1))
copy(s2, s1)
// ✅ For memory release
s = s[:0] // len=0, keep cap (reuse)
s = nil // GC-able
Checklist
- Pre-allocate when slice size is predictable
- Copy when using small part of large slice
- Use index when referencing element addresses in loops
- Consider pointer passing when modifying slices in functions
- Measure performance with benchmarks
References
One-line summary: Go slices are (ptr, len, cap) structs that reference arrays, reallocate when capacity is insufficient during append, so optimize performance with pre-allocation and watch for memory leaks when slicing.