Go Performance Guide
Memory Management

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:

  1. Allocate a new, larger buffer
  2. Copy all existing elements to the new buffer
  3. 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 type
  • length - 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=. -benchmem

Expected 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/op

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

On this page