4496
Programming

Optimizing Go Performance: Stack vs Heap Allocations for Slices

Posted by u/Merekku · 2026-05-02 14:43:40

Introduction

Go developers are constantly seeking ways to improve the performance of their programs. One of the most impactful areas is memory management, specifically how and where data is allocated. This article explores a key optimization introduced in recent Go releases: shifting allocations from the heap to the stack, particularly for slices. We'll examine the overhead of heap allocations, the benefits of stack allocation, and how constant-sized slices can be a game-changer for performance-critical code.

Optimizing Go Performance: Stack vs Heap Allocations for Slices
Source: blog.golang.org

Why Stack Allocations Matter

Heap allocations are expensive. Each time a Go program requests memory from the heap, a significant amount of runtime code runs to fulfill that request. This not only slows down the program but also adds pressure on the garbage collector, which must later free that memory. Even with improvements like the Green Tea garbage collector, heap-related overhead remains substantial.

In contrast, stack allocations are far cheaper—sometimes nearly free. Stack-allocated data is automatically reclaimed when the function returns, placing no burden on the garbage collector. Moreover, stack memory is reused promptly, improving cache locality and overall performance. For these reasons, the Go team has focused on enabling more stack allocations.

The Heap Overhead of Growing Slices: A Common Pattern

Consider a typical pattern: processing a stream of tasks from a channel by appending them to a slice.

func process(c chan task) {
    var tasks []task
    for t := range c {
        tasks = append(tasks, t)
    }
    processAll(tasks)
}

At first glance, this seems straightforward. But under the hood, the append function triggers a series of heap allocations as the slice grows. Let's trace the runtime behavior:

  • First iteration: No backing array exists. append allocates a new array of size 1.
  • Second iteration: Array of size 1 is full. A new array of size 2 is allocated, and the old array becomes garbage.
  • Third iteration: Array of size 2 is full. A new array of size 4 is allocated; size-2 array is garbage.
  • Fourth iteration: Array of size 4 has one free slot. append reuses it—no allocation.
  • Fifth iteration: Array size 4 is full. Allocation of size 8.
  • And so on, with the capacity doubling each time the slice fills.

This doubling strategy reduces allocations over time, but the initial growth phase is costly. If your slice never becomes large (e.g., it processes only a few tasks per invocation), you repeatedly pay the startup overhead. The heap allocations produce garbage, slow down the program, and waste CPU cycles.

Constant-Sized Slices: A Solution on the Stack

To mitigate this overhead, Go now supports stack allocation of constant-sized slices. If the compiler can determine the maximum size of a slice at compile time, it may allocate the backing array on the stack instead of the heap. This eliminates both the allocation cost and garbage collector pressure.

How does this work? Consider the same task-processing function, but with a known upper bound on the number of tasks:

func process(c chan task, maxTasks int) {
    const max = 100
    var tasks [max]task
    n := 0
    for t := range c {
        if n >= max {
            break
        }
        tasks[n] = t
        n++
    }
    processAll(tasks[:n])
}

Here, the backing array [100]task is a fixed-size array allocated on the stack (assuming it fits within the stack frame). The slice tasks[:n] is then used from that array. No heap allocations occur at all—the entire operation uses stack memory. This is particularly beneficial for hot paths where small slices are repeatedly created.

How Go's Compiler Optimizes Stack Allocation

Go's compiler performs an escape analysis to determine whether a variable can be safely allocated on the stack. For slices, if the precise size is known and the slice doesn't escape (i.e., its address isn't passed to other goroutines or stored in a heap-allocated structure), the compiler will allocate the backing array on the stack. This optimization applies not only to arrays but also to slices created with make when the size parameter is a constant.

For example, make([]task, 100) with a constant size may allocate on the stack. Similarly, composite literals like []task{t1, t2, t3} with a fixed set of elements can be stack-allocated.

This optimization is transparent to the developer—you don't need to manually rewrite code to use arrays. However, being aware of it can help you structure your code to benefit from stack allocation:

  • Use constant sizes when possible, e.g., const maxN = 100.
  • Avoid letting slices escape (e.g., returning them from a function without a constant size may force heap allocation).
  • Prefer small, fixed-size slices in performance-critical loops.

Conclusion

The shift toward stack allocation for slices is a powerful performance optimization in Go. By reducing heap allocations and garbage collector load, programs can run faster and more predictably. The key takeaway: when you know the maximum size of a slice, make it explicit. Let the compiler do the heavy lifting of allocating that slice on the stack. For many real-world applications—especially those processing small batches of data repeatedly—this can yield significant speedups.

To stay informed about further improvements, check the Go release notes and profiler documentation. Experiment with your own hot paths to see where stack allocation can make a difference.