How to Reduce GC Pressure in Go

Reduce Go GC pressure by minimizing heap allocations and reusing objects via sync.Pool.

The cost of automatic memory management

You are running a Go service handling thousands of requests per second. The CPU usage looks stable, but latency spikes appear every few seconds. The profiler shows time spent in runtime.gc. The garbage collector is pausing your goroutines to scan and free memory. This is GC pressure. Your code is allocating memory faster than the GC can keep up, or allocating objects that force expensive heap scans. Reducing GC pressure improves latency, lowers CPU overhead, and makes your service more predictable. The goal isn't to eliminate allocations. The goal is to make allocations cheap, predictable, and reusable.

Stack versus heap: the workbench and the warehouse

Go manages memory automatically. You don't call malloc or free. The runtime decides where variables live. Small, short-lived variables usually stay on the stack. The stack is fast. It grows and shrinks with function calls. When a function returns, its stack frame vanishes. No cleanup needed. Large or long-lived variables go to the heap. The heap is a big pool of memory shared across goroutines. When the heap fills up, the garbage collector runs. It scans the heap, finds objects nobody references, and frees them. While the GC runs, your goroutines pause. This pause is the cost of automatic memory management.

Think of the heap as a warehouse and the stack as a workbench. The workbench is right in front of you. You grab tools, use them, and put them back instantly. No manager needed. The warehouse is huge. You can store anything. But you need a forklift to move items, and a manager to check what's still needed. The manager is the GC. If you keep running to the warehouse for every small screw, the manager gets busy. If you keep screws on your workbench, you work faster. Stack allocation is the workbench. Heap allocation is the warehouse. Keep data on the workbench when you can.

GC pressure is the rate at which you force the GC to work. High pressure means frequent pauses, higher CPU usage for the GC, and unpredictable latency. The compiler tries to keep variables on the stack. This process is called escape analysis. If the compiler can prove a variable doesn't outlive the function, it stays on the stack. If the variable escapes, it goes to the heap.

How escape analysis decides allocation

The compiler tracks every variable. It checks where the variable is used. If the variable is returned by pointer, stored in a global, captured by a closure, or passed to an interface, it likely escapes. The compiler makes these decisions at compile time. You can inspect the decisions using the compiler flags.

Run go build -gcflags=-m to see escape analysis output. The compiler prints messages like moves to heap for variables that escape. This flag is essential for debugging allocation issues. It shows exactly why a variable ended up on the heap.

Here's how return types affect allocation. Returning a value keeps the data on the stack if the struct is small. Returning a pointer forces the data onto the heap because the caller holds the pointer after the function returns.

// Config holds small settings.
type Config struct {
    Port int
    Host string
}

// makeConfig returns a value.
// The struct stays on the stack because it doesn't escape.
func makeConfig() Config {
    return Config{Port: 8080, Host: "localhost"}
}

// makeConfigPtr returns a pointer.
// The struct escapes to the heap because the caller holds the pointer.
func makeConfigPtr() *Config {
    return &Config{Port: 8080, Host: "localhost"}
}

In makeConfig, the compiler sees the struct is returned by value. The caller gets a copy. The original struct can stay on the stack. In makeConfigPtr, the function returns a pointer. The memory must survive after the function returns. The compiler allocates the struct on the heap. The pointer points to heap memory.

Escape analysis is the compiler's best friend. Trust it, but verify with -gcflags=-m.

Interfaces force heap allocation

Interfaces are powerful, but they have a memory cost. An interface value is a pair: a pointer to the type information and a pointer to the data. When you assign a concrete value to an interface, the compiler often copies the value to the heap so the interface can hold a pointer to it. This happens even if the original value was on the stack.

Here's how assigning to an interface triggers allocation. The string s starts on the stack. Assigning it to any forces a heap copy.

func escapeToInterface() any {
    // s is a string literal. It could stay on the stack.
    s := "hello"

    // Assigning to any creates an interface value.
    // The compiler copies s to the heap so the interface can point to it.
    var i any = s

    return i
}

The variable i holds an interface. The interface stores a pointer to the data. The data must live as long as the interface lives. The compiler moves the data to the heap. This allocation happens silently. It adds up in hot paths. If you return an interface from a function called millions of times, you're allocating millions of heap objects.

The community mantra is "accept interfaces, return structs." Functions should accept interfaces as parameters but return concrete structs. This keeps the allocation burden on the caller, who might be able to reuse the struct or keep it on the stack. It also reduces interface allocations in the library code.

Reusing objects with sync.Pool

Sometimes you need heap allocation. You're building a request handler that creates a buffer, uses it, and discards it. Every request allocates a new buffer. The GC has to clean up millions of buffers. sync.Pool solves this. It holds a collection of objects that may be individually stored and retrieved simultaneously. Objects in a pool can be reused.

sync.Pool is designed for short-lived objects. It's not a cache that guarantees retention. The pool can be cleared by the GC between uses. You must handle the case where the pool is empty. The New function provides a fallback.

Here's a request handler using a pool to reuse byte slices. The pool reduces allocations by recycling the underlying array.

var bufferPool = sync.Pool{
    // New is called when the pool is empty.
    // It returns a fresh buffer for the first use.
    New: func() any {
        return make([]byte, 0, 1024)
    },
}

func handleRequest(w http.ResponseWriter, r *http.Request) {
    // Get retrieves an object from the pool.
    // If the pool is empty, New is called.
    buf := bufferPool.Get().([]byte)

    // Reset length to zero, keep capacity.
    // The underlying array is reused.
    buf = buf[:0]

    // Use the buffer for processing.
    // ... read data into buf ...

    // Put returns the object to the pool.
    // The buffer is reused by the next request.
    bufferPool.Put(buf)
}

The New function creates a slice with capacity 1024. Get pulls a slice from the pool. If the pool is empty, New runs. The handler resets the slice length to zero. The capacity remains. The handler uses the buffer. Put returns the slice to the pool. The next request gets the same underlying array. No allocation for the array itself.

sync.Pool is per-P (processor). Each processor has a local pool. Access is lock-free for the local P. This makes pools extremely fast. They avoid contention. The pool is ideal for objects allocated and discarded frequently in a hot path.

Pools are for reuse, not storage. Return objects promptly or leak memory.

Pitfalls and runtime panics

Using sync.Pool introduces risks. The pool stores any values. You must type assert when retrieving. If you assert to the wrong type, the program panics.

The runtime panics with panic: interface conversion: interface {} is *MyStruct, not *OtherStruct if you assert the wrong type. This happens when you put one type in the pool but expect another. Always verify the type or use a wrapper struct to enforce type safety.

Another risk is holding references. If a goroutine holds a reference to a pooled object, the object can't be returned to the pool. The pool can't reuse it. The object stays alive longer than needed. This defeats the purpose of the pool. Ensure all references are cleared before calling Put.

Holding a reference to a pooled object prevents reuse. The object leaks until the reference is dropped.

sync.Pool objects can be garbage collected. The pool doesn't protect objects from the GC. If the GC runs while the pool is idle, it might clear the pool. Your code must handle this. The New function ensures you always get a valid object, even if the pool was cleared. Don't assume the pool retains objects across GC cycles.

Building strings without allocations

Strings are immutable in Go. Every concatenation creates a new string. If you build a string in a loop, you allocate a new string on every iteration. This creates massive GC pressure.

Use strings.Builder to build strings efficiently. The builder reuses an internal buffer. It avoids allocating a new string for every append.

Here's how to build strings without allocating a new slice for every append. The builder grows the buffer internally.

func buildMessage(parts []string) string {
    // Builder reuses internal buffer.
    // No allocation per append.
    var b strings.Builder

    // Grow pre-allocates capacity.
    // Reduces resizing during writes.
    b.Grow(len(parts) * 10)

    for _, p := range parts {
        // WriteString appends without copying.
        // The buffer expands as needed.
        b.WriteString(p)
    }

    // String returns the final string.
    // One allocation for the result.
    return b.String()
}

The Grow call pre-allocates capacity. This reduces the number of times the builder resizes the buffer. WriteString appends data directly. The builder manages the buffer. String returns the final result. This pattern reduces allocations from O(n) to O(1) for the building phase.

Strings are immutable. Every concatenation creates a new string. Use a builder for loops.

Decision matrix for allocation strategies

Choosing the right allocation strategy depends on the lifetime and usage of the data. Use the wrong strategy and you waste CPU or memory. Use the right strategy and your service scales smoothly.

Use stack allocation when the variable is small and doesn't escape the function scope. The compiler handles this automatically. Trust escape analysis for local variables.

Use heap allocation via pointers when you need to share data across goroutines or the value is too large for the stack. Pointers enable sharing. Large structs should be passed by pointer to avoid copying.

Use sync.Pool when you allocate and discard the same type of object frequently in a hot path. Pools shine for request-scoped buffers, temporary structs, and reusable encoders.

Use strings.Builder or bytes.Buffer from a pool when constructing large strings or byte sequences in a loop. Builders avoid per-iteration allocations.

Use value types for small structs that are copied frequently. Passing a small struct by value is cheaper than dereferencing a pointer. The CPU cache handles small copies efficiently.

Avoid sync.Pool when objects hold state that must be cleared, or when allocation is rare. Pools add complexity. If you allocate an object once per second, a pool adds overhead without benefit.

GC pressure is a design problem. Fix the allocation rate, not the GC threshold.

Where to go next