DEV Community

Lakshya Negi
Lakshya Negi

Posted on

2

8 Memory-Efficient Go Coding Techniques For Better GC Performance

Go’s garbage collector is impressively engineered for low latency and minimal pauses, but even the best GC can become a bottleneck when your code generates excessive garbage. After optimizing numerous Go applications, these eight practical techniques consistently help reduce GC pressure while maintaining clean, readable code.

1. Minimize Allocations

Every allocation creates work for the garbage collector. The Go GC must trace through all reachable objects using its tri-colour marking system, and each allocation increases this workload.

During a collection cycle, Go’s GC must:

  • Mark all new allocations as “white” (potentially collectable)
  • Trace through object graphs to find reachable objects
  • Move objects between white→gray→black states as it determines reachability

Each additional object increases this workload linearly, and complex object graphs increase it even more.

I’ve seen significant performance improvements simply by reusing variables:

// Instead of this:
for _, item := range items {
    result := process(item)  // Creates a new variable each iteration    
    // use result
}

// Do this:
var result SomeType
for _, item := range items {    
    result = process(item)  // Reuses the same variable    
    // use result
}
Enter fullscreen mode Exit fullscreen mode

This simple change can dramatically reduce GC pressure in hot paths processing thousands of items.

2. Pre-allocate Slices and Maps

When a slice or map grows beyond its capacity, Go allocates a new, larger backing array and copies the data. The old backing array becomes garbage.

Go’s slice growth strategy typically doubles the capacity when more space is needed. This means:

  • A slice growing from 1,000 to 1,001 elements will allocate a new 2,000-element array
  • The original 1,000-element array becomes garbage
  • This creates a “sawtooth pattern” in memory usage that triggers the GC more frequently

The GC pacing algorithm detects these allocation spikes and may trigger collection cycles in response, causing more frequent pauses.

When a data processing pipeline was triggering GC cycles too frequently, pre-allocation solved the problem:

// Instead of this:
users := []User{}  // Will grow and reallocate multiple times

// Do this:
users := make([]User, 0, 1000)  // Pre-allocate capacity for 1000 users
Enter fullscreen mode Exit fullscreen mode

This simple change reduced GC frequency by 30% in our services.

3. Object Pooling for Frequently Used Objects

Short-lived objects like buffers in a web server handling thousands of requests create constant allocation pressure.

The Go GC uses a target heap growth formula to decide when to collect:

next_gc = live_heap * (1 + GOGC/100)
Enter fullscreen mode Exit fullscreen mode

Where GOGC is typically 100 (meaning the heap can double before collection).

By pooling objects, you:

  • Lower the rate at which this target is approached
  • Reduce “marking” work since pooled objects stay marked as “black” (in-use)
  • Lower the heap fragmentation that occurs when objects are frequently allocated and freed
var bufferPool = sync.Pool{    
    New: func() interface{} {        
        return make([]byte, 4096)    
    },
}

func processRequest() {    
    buffer := bufferPool.Get().([]byte)    
    defer bufferPool.Put(buffer)    
    // Use buffer...
}
Enter fullscreen mode Exit fullscreen mode

This pattern reduced p99 latency by 15% in a high-throughput API gateway.

4. Avoid String Concatenation in Loops

Strings in Go are immutable. Each concatenation creates a new string, leaving the old ones for garbage collection.

Internally, strings in Go are represented as:

type stringStruct struct {    
    str unsafe.Pointer // Pointer to the backing byte array    
    len int            // Length of the string
}
Enter fullscreen mode Exit fullscreen mode

Each concatenation:

  1. Allocates a new backing array
  2. Copies both original strings into it
  3. Creates a new string header
  4. Leaves the original strings to be collected

This is particularly problematic for the GC because strings often have short lifetimes during concatenation, which can trigger the “generational hypothesis” problem that Go’s non-generational collector doesn’t handle optimally.

In a report generator, switching from concatenation to strings.Builder made a dramatic difference:

// Instead of this:
var result string
for i := 0; i < 1000; i++ {    
    result += fmt.Sprintf("%d,", i)  // Creates garbage on each iteration
}

// Do this:
var builder strings.Builder
builder.Grow(10000)  // Pre-allocate approximate size
for i := 0; i < 1000; i++ {    
    fmt.Fprintf(&builder, "%d,", i)
}
result := builder.String()
Enter fullscreen mode Exit fullscreen mode

This change turned report generation from seconds to milliseconds.

5. Be Careful with Closures

Closures capture variables by reference, potentially keeping entire object graphs alive.

When the Go GC determines reachability, it follows all pointers from root objects. A closure is implemented as a struct containing:

  • A function pointer
  • References to all captured variables

The GC must mark everything reachable from these captured variables, which can create “accidental retention” where large structures remain in memory because a small part is referenced by a closure.

When debugging a memory leak, I found a seemingly innocent event handler was capturing a large data structure:

// Instead of this:
largeObject := createLargeObject()
handler := func(w http.ResponseWriter, r *http.Request) {    
// largeObject is captured and can't be garbage collected    
    fmt.Fprintf(w, "Using %v", largeObject.Name)
}

// Do this:
type Handler struct {    
    name string  // Store just what you need
}

largeObject := createLargeObject()
handler := &Handler{
    name: largeObject.Name,
}
// Now largeObject can be garbage collected
Enter fullscreen mode Exit fullscreen mode

This pattern reduced memory usage by hundreds of megabytes in a large application.

6. Use Value Types for Small Objects

Value types can live on the stack rather than the heap, bypassing the GC entirely.

Go uses escape analysis during compilation to determine where values should be allocated:

  • Stack allocations have zero GC overhead as they’re automatically reclaimed when functions return
  • Heap allocations are subject to the GC’s mark-and-sweep algorithm

The compiler’s escape analysis has specific rules for determining when values escape to the heap:

  • Values whose addresses are taken and might outlive the function
  • Values too large for the stack
  • Values with unknown size at compile time
// Instead of this:
func createUser() *User {    
    return &User{Name: "Bob"}  // Escapes to the heap
}

// Do this when possible:
func createUser() User {    
    return User{Name: "Bob"}  // May stay on the stack
}
Enter fullscreen mode Exit fullscreen mode

Applying this pattern throughout a codebase reduced GC overhead by 20%.

7. Batch Processing for High-Volume Operations

Processing items one by one often leads to more allocations and poor memory locality.

Go’s GC performance is directly tied to working set size and memory access patterns:

  • The GC must scan all objects to determine liveness
  • Better memory locality leads to better CPU cache utilization during GC scans
  • Batch processing reduces the “mark” phase duration by localizing memory access

Additionally, batching can reduce the total number of write barriers executed. Write barriers are extra instructions that the GC inserts to track pointers written during concurrent collection, and they add overhead to normal execution.

// Instead of this:
for _, item := range hugeList {    
    processItem(item)  // May allocate each time
}

// Do this:
batchSize := 100
for i := 0; i < len(hugeList); i += batchSize {    
    end := i + batchSize
  if end > len(hugeList) {        
      end = len(hugeList)
    }    
    processBatch(hugeList[i:end])  // Process many items with fewer allocations
}
Enter fullscreen mode Exit fullscreen mode

For large datasets, this approach reduced processing time by 40%.

8. Minimize Interface Conversions

Interface values in Go consist of both a type pointer and a data pointer, requiring heap allocations in many cases.

Internally, interfaces are represented as:

type iface struct {    
    tab  *itab          // Contains type information    
    data unsafe.Pointer // Points to the actual data
}
Enter fullscreen mode Exit fullscreen mode

Each interface conversion potentially allocates this structure. Furthermore, boxing values into interfaces often forces stack-allocated values to escape to the heap, where they become subject to garbage collection.

The GC must trace both the interface structure and the underlying value, doubling the work compared to direct value usage.

// Instead of this:
func process(items []interface{}) {  // Requires boxing each item    
    for _, item := range items {        
        // ...    
    }
}

// Do this:
func process[T any](items []T) {  // Uses generics, no boxing    
    for _, item := range items {        
        // ...    
    }
}
Enter fullscreen mode Exit fullscreen mode

Refactoring to use generics instead of empty interfaces significantly reduced allocations and GC overhead.

Conclusion

Go’s garbage collector is remarkably efficient, but understanding how it works and writing code that generates less garbage can dramatically improve performance. These techniques aren’t about fighting the garbage collector—they’re about working harmoniously with it by generating less garbage in the first place.

Always measure before and after applying these optimizations. Use Go’s built-in profiling tools to identify where GC is becoming a bottleneck, then apply these patterns strategically. Not every application will need all of these optimizations, but understanding them will make you a more effective Go developer, especially when working on performance-critical systems.

What GC-friendly techniques have you found effective in your Go applications? Share your experiences in the comments below!

Image of Datadog

How to Diagram Your Cloud Architecture

Cloud architecture diagrams provide critical visibility into the resources in your environment and how they’re connected. In our latest eBook, AWS Solution Architects Jason Mimick and James Wenzel walk through best practices on how to build effective and professional diagrams.

Download the Free eBook

Top comments (1)

Collapse
 
devflex-pro profile image
Pavel Sanikovich

Essential read! Optimizing memory in Go improves GC performance, boosts efficiency, and helps you write professional-grade code. 🚀

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay