Memory Preallocation
Optimize Go performance by preallocating slices and maps to reduce allocation overhead and garbage collection pressure.
Memory Preallocation
Preallocation is one of the most impactful performance optimizations in Go. By sizing containers before populating them, you eliminate the overhead of dynamic growth and reduce pressure on the garbage collector. This guide covers when and how to preallocate effectively.
Why Preallocation Matters
When you create a slice without specifying capacity, Go allocates an initial buffer. As you append elements beyond the current capacity, Go must:
- Allocate a new, larger buffer
- Copy all existing elements to the new buffer
- Free the old buffer (adding to GC pressure)
This happens multiple times during slice growth, resulting in wasted CPU cycles and increased memory allocations.
For maps, without preallocation, Go must perform internal resizing as you add more key-value pairs, causing hash table rehashing and memory fragmentation.
Key Insight: Preallocation trades upfront memory usage for elimination of runtime allocation overhead. The larger the final container, the greater the benefit.
How Go's Slice Growth Works
Go uses a growth strategy that has evolved over versions:
- Go 1.14 and earlier: Capacity doubles until reaching 1024 elements, then grows by 25%
- Go 1.15+: Uses a formula that approximates 1.25x growth across all sizes for better memory efficiency
Let's see this in action:
package main
import "fmt"
func main() {
// Track capacity growth
s := make([]int, 0)
prev := cap(s)
for i := 0; i < 100; i++ {
s = append(s, i)
if cap(s) != prev {
fmt.Printf("Growth: len=%d, cap=%d -> %d (grew %d times)\n",
len(s), prev, cap(s), cap(s)/prev)
prev = cap(s)
}
}
fmt.Printf("Final: len=%d, cap=%d\n", len(s), cap(s))
}Output shows how capacity increases incrementally: 0 → 1 → 2 → 4 → 8 → 16 → 32 → 64 → 128 → ...
Slice Preallocation
The most common preallocation pattern uses the three-argument make() function:
// Without preallocation (inefficient)
var items []string
for _, name := range names {
items = append(items, name)
}
// With preallocation (efficient)
items := make([]string, 0, len(names))
for _, name := range names {
items = append(items, name)
}The three arguments to make() are:
Type- the element typelength- initial number of elements (usually 0 for allocation-only)capacity- total elements the slice can hold before growing
When length equals capacity, future appends trigger reallocation. By setting capacity upfront, you eliminate all reallocations.
Benchmark: Preallocation Impact
package prealloc
import (
"testing"
)
func BenchmarkNoPrealloc(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var items []int
for j := 0; j < 1000; j++ {
items = append(items, j)
}
}
}
func BenchmarkPrealloc(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
items := make([]int, 0, 1000)
for j := 0; j < 1000; j++ {
items = append(items, j)
}
}
}
func BenchmarkPreallocExact(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
items := make([]int, 1000)
for j := 0; j < 1000; j++ {
items[j] = j
}
}
}Run with:
go test -bench=. -benchmemExpected results on modern hardware:
BenchmarkNoPrealloc-8 200 5500000 ns/op ~30 allocs/op
BenchmarkPrealloc-8 1000 1100000 ns/op 1 alloc/op
BenchmarkPreallocExact-8 1000 1050000 ns/op 1 alloc/opThe difference is dramatic: preallocation reduces both time and allocations by ~10x for this workload.
Map Preallocation
Maps must be preallocated to avoid runtime rehashing overhead:
// Without preallocation
m := make(map[string]int)
for _, key := range keys {
m[key]++
}
// With preallocation
m := make(map[string]int, len(keys))
for _, key := range keys {
m[key]++
}When you don't preallocate a map, Go estimates the required bucket count based on initial insertions. Once load factor (elements / bucket count) exceeds a threshold (~6.5), Go rehashes the entire map into a larger table, copying all entries.
Map Preallocation Benchmark
func BenchmarkMapNoPrealloc(b *testing.B) {
b.ReportAllocs()
keys := generateKeys(10000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
m := make(map[string]int)
for _, key := range keys {
m[key]++
}
}
}
func BenchmarkMapPrealloc(b *testing.B) {
b.ReportAllocs()
keys := generateKeys(10000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
m := make(map[string]int, len(keys))
for _, key := range keys {
m[key]++
}
}
}
func generateKeys(count int) []string {
keys := make([]string, count)
for i := 0; i < count; i++ {
keys[i] = fmt.Sprintf("key_%d", i)
}
return keys
}Results show 2-3x improvement in time and fewer allocations with preallocation.
Real-World Scenario: JSON Parsing
JSON unmarshaling often creates slices of structs. Knowing the expected size allows efficient preallocation:
// Parsing an API response with a known field count
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
// Inefficient: no preallocation
func ParseUsersInefficient(data []byte) ([]User, error) {
var users []User
if err := json.Unmarshal(data, &users); err != nil {
return nil, err
}
return users, nil
}
// Efficient: preallocate for expected size
func ParseUsersEfficient(data []byte, expectedCount int) ([]User, error) {
users := make([]User, 0, expectedCount)
// In real scenarios, you'd parse incrementally or use streaming JSON
var raw []User
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
users = append(users, raw...)
return users, nil
}
// Best for streaming APIs: use json.Decoder with preallocation
func ParseUsersStreaming(r io.Reader, expectedCount int) ([]User, error) {
users := make([]User, 0, expectedCount)
decoder := json.NewDecoder(r)
// Read array opening bracket
t, err := decoder.Token()
if err != nil {
return nil, err
}
if t != json.Delim('[') {
return nil, fmt.Errorf("expected array")
}
for decoder.More() {
var u User
if err := decoder.Decode(&u); err != nil {
return nil, err
}
users = append(users, u)
}
return users, nil
}Real-World Scenario: HTTP Handler
Request data often requires accumulation in slices. Preallocation improves handler latency:
type RequestHandler struct {
// Reusable slice for processing
items []string
}
// Without preallocation knowledge - allocates in handler
func (h *RequestHandler) Process(r *http.Request) error {
var items []string
for _, item := range r.Form["items"] {
// Processing
items = append(items, strings.ToLower(item))
}
// Use items...
return nil
}
// With preallocation - much faster
func (h *RequestHandler) ProcessFast(r *http.Request) error {
formItems := r.Form["items"]
items := make([]string, 0, len(formItems))
for _, item := range formItems {
items = append(items, strings.ToLower(item))
}
// Use items...
return nil
}
// Benchmark showing request handling speed difference
func BenchmarkHandler(b *testing.B) {
req := httptest.NewRequest("POST", "/", nil)
req.PostForm = url.Values{
"items": make([]string, 100),
}
for i := 0; i < 100; i++ {
req.PostForm.Set("items", fmt.Sprintf("item_%d", i))
}
b.ReportAllocs()
handler := &RequestHandler{}
b.ResetTimer()
for i := 0; i < b.N; i++ {
handler.ProcessFast(req)
}
}Guidelines for Effective Preallocation
When to Preallocate
- Known final size: You know exactly how many elements you'll add (best case)
- Bounded estimate: You can reasonably estimate within 20% (good case)
- Large collections: 100+ elements - the overhead becomes significant
- Hot paths: Code executed frequently where allocations add up
- Latency-critical: Real-time systems where GC pauses matter
When Preallocation Might Be Premature
- Unknown size: Can't estimate final size reliably
- Small collections: Fewer than 10 elements - overhead is negligible
- One-time operations: Not in hot paths
- Memory-constrained: Preallocating wastes memory if actual size is much smaller
Anti-Patterns to Avoid
// BAD: Preallocating way too large wastes memory
users := make([]User, 0, 1000000) // Only need 100!
// BAD: Forgetting that preallocation doesn't initialize length
items := make([]int, 100)
items = append(items, 1) // Now length is 101!
// BAD: Not considering worst-case scenarios
items := make([]int, 0, 50) // Might need 100...
// GOOD: Estimate with safety margin
expectedSize := len(input) + 10 // Small buffer for contingencies
items := make([]int, 0, expectedSize)Measurement and Profiling
Use go test -benchmem to measure allocation counts:
# Profile allocations in your program
go build -cpuprofile=cpu.prof -memprofile=mem.prof ./cmd/myapp
go tool pprof mem.prof
# Check compiler escape analysis
go build -gcflags="-m=2" ./... 2>&1 | grep "allocating"Summary
Preallocation is a high-impact, low-effort optimization:
- Slice preallocation: Use
make([]T, 0, capacity)when size is known - Map preallocation: Use
make(map[K]V, expectedSize)for better performance - Impact: 5-10x faster for large collections, fewer GC allocations
- Trade-off: Uses slightly more memory upfront for significant speed gain
- Measurement: Always benchmark - preallocate where it matters most
Start by profiling your application to find allocation hotspots, then apply preallocation strategically for maximum impact.