Understanding Stack Allocation for Slices in Go
In Go, memory allocation can happen on the stack or on the heap. Stack allocations are significantly cheaper—often nearly free—and place no burden on the garbage collector, because they are automatically reclaimed when the function returns. The Go compiler applies various optimizations to move allocations from heap to stack, particularly for slices whose size is known at compile time. This article explores how stack allocation works for constant-sized slices, the overhead of dynamic growth, and strategies to minimize heap allocations in hot code paths.
Why are stack allocations cheaper than heap allocations?
Stack allocations are cheaper because they involve simply moving the stack pointer—no complex memory management logic is needed. In contrast, heap allocations require the runtime to find a free block, possibly trigger garbage collection, and update metadata. Stack allocations also improve cache locality and impose zero load on the garbage collector, since the stack frame is discarded entirely when the function exits. This makes them ideal for frequently allocated objects like small slices or temporary structures.

What happens when you append to a slice without pre-allocating capacity?
When you append to a slice that has no backing array (nil slice) or is full, Go must allocate a new array on the heap. The growth strategy doubles the capacity each time the slice fills up. For example, starting from size 0, the first append allocates a backing array of size 1. The next append allocates size 2 (copying the old element), then size 4, 8, and so on. This exponential growth reduces total allocations over many appends, but the early iterations are wasteful: each creates a new heap allocation and leaves the old array as garbage for the GC. For slices that stay small, this overhead can be significant in hot loops.
How does the Go compiler optimize constant-sized slices?
If the compiler can determine the maximum capacity a slice will ever need—for example, when the size is a constant like make([]int, 100) or when the number of iterations is known—it may place the backing array on the stack instead of the heap. This is called stack allocation of constant-sized slices. The compiler analyzes escape analysis and lifetime to decide if the slice can live on the stack. When it can, the allocation becomes essentially free, and the garbage collector is never involved. This optimization is especially valuable in performance-critical code where small slices are created repeatedly.
What is the "startup phase" problem with growing slices?
The startup phase refers to the first few iterations of appending to a slice that starts empty. Each time capacity is exhausted, a new heap allocation occurs (sizes 1, 2, 4, 8, etc.). These allocations are small but numerous, and each leaves a dead backing array that must be collected. If your slice never grows beyond a small size, say 8 elements, you might perform 4 heap allocations just to add 8 items. This is wasteful compared to a single stack allocation of size 8. In hot code paths, such overhead can degrade performance and increase GC pressure.
How can you pre-allocate a slice to avoid repeated heap allocations?
If you know the number of elements you will append, use make([]T, 0, capacity) to pre-allocate a backing array with the needed capacity. This ensures only one allocation, and if the capacity is small and constant, the compiler may even place it on the stack. For example: tasks := make([]task, 0, len(channelData)). Pre-allocation also eliminates the startup phase overhead and reduces garbage. Always consider whether you can estimate capacity in advance, especially in loops that process known numbers of items. The trade-off is that you might overallocate if your estimate is too large, but the memory waste is usually minor compared to the allocation savings.
Does stack allocation completely eliminate garbage collector load?
Yes, stack allocations are automatically freed when the function returns, so they never reach the garbage collector. This reduces GC scan time, marking effort, and memory fragmentation. However, not all allocations can escape to the stack—if a slice is returned from a function or stored in a global variable, it must live on the heap. The Go compiler uses escape analysis to determine where each allocation should go. Optimizing code to keep allocations small and short-lived increases the chance they stay on the stack, minimizing GC impact.
What are the trade-offs of using stack allocation for slices?
The main trade-off is that stack space is limited. Large stack allocations can cause stack overflow or exhaust the per-goroutine stack limit (which starts small but grows dynamically). For this reason, constant-sized slices that are stack-allocated must be reasonably small—typically a few hundred bytes to a few kilobytes. Larger slices should use heap allocation. Additionally, if a slice escapes (e.g., its address is passed to another goroutine), it cannot be stack-allocated. The compiler balances these factors, and developers should profile to see if stack allocation is actually happening.