Golang Memory Best Practice

The article discusses optimizing memory in Go by managing global variables, string operations, slices, struct alignment, and goroutines to prevent leaks and reduce unnecessary allocations.

Golang

Backend

Performance

Optimization

Table of Contents

1. Minimize Heap Allocations

In Go, small and short-lived variables are typically allocated on the stack, which is much faster than heap allocation because it avoids the overhead of garbage collection (GC). The heap requires GC to track and clean up unused memory, which can introduce latency and impact performance.

To optimize memory usage and improve performance, prefer value types over pointers whenever possible. Value types keep data on the stack, reducing the need for heap allocations and minimizing GC pressure. However, if a variable needs to be shared across multiple functions or has a large size that would cause excessive copying, using pointers may be more appropriate.

// stack allocation (faster, auto-cleanup)
func add(x, y int) int {
    return x + y
}

// heap allocation (garbage collector needed)
func add(x, y *int) int {
    return *x + *y
}

2. Global and Local Variables

Global variables persist throughout a program’s lifecycle, increasing memory usage. Instead, prefer function-scoped or struct-scoped variables to limit memory footprint and avoid unintended side effects.

However, there are scenarios where global variables are useful or even necessary:

  • Configuration and Constants: Values that are read often but rarely change, such as application settings, database connection strings, or predefined limits.
  • Shared State: When multiple parts of the program need to access a common resource, like an in-memory data store.
  • Performance Optimization: If allocating and deallocating memory repeatedly causes overhead, a global variable may reduce the cost.

TLDR:

  • Use local variables when data is temporary and specific to a function’s execution.
  • Use global variables when maintaining state across multiple function calls improves efficiency and prevents redundant computations.
// global variable lives for the entire program runtime
var cache = make(map[string]int)

// localized memory allocation
func processData() {
    data := make([]int, 1000) // gets cleaned up after function exits
}

3. Consider bytes.Buffer for String Concatenation

String concatenation using + creates a new string on every operation, leading to excessive memory allocations and degraded performance. Instead, bytes.Buffer or strings.Builder should be used to minimize allocations and improve efficiency.

import "bytes"

// creates new string on every loop (expensive)
func contact() string {
    s := ""
    for i := 0; i < 1000; i++ {
        s += "hello"
    }
    return s
}

// ues bytes.Buffer to reduce allocations
func concat() string {
    var b bytes.Buffer
    for i := 0; i < 1000; i++ {
        b.WriteString("hello")
    }
    return b.String()
}

Summary:

  • Use + for quick, simple concatenations of short strings.
  • Use bytes.Buffer (or strings.Builder) for repeated or large string operations to avoid performance bottlenecks.

4. Be Careful with Large Slices

When appending to a slice, Go dynamically resizes it by doubling its capacity when needed. While this helps amortize reallocation costs, it can lead to high memory usage if the growth pattern is unpredictable.

When Appending is Acceptable:

  • The growth rate is unpredictable – If you don't know the final size in advance, appending provides a balance between flexibility and performance.
  • When working with small slices – The cost of resizing is negligible for small data sets.

Use make() to allocate exactly what’s needed

// preallocate slice size
s := make([]int, 1000)

// copy instead of append to avoid excessive resizing
small := []int{1, 2, 3}
large := make([]int, len(small))
copy(large, small)

Use copy() to avoid unnecessary allocations

// appending may cause multiple resizes
small := []int{1, 2, 3}
large := []int{}
large = append(large, small...) // may cause multiple allocations

// use copy() to avoid resizing
large := make([]int, len(small))
copy(large, small) // directly copies elements, no extra allocation

5. Avoid Holding Unnecessary References

Go's garbage collector (GC) automatically frees unused memory, but it won’t reclaim memory if an object is still referenced in a long-lived structure, even if it’s no longer needed. This can lead to memory leaks and increased heap usage.

Why Go May Not Free Memory

Go uses a tracing garbage collector, meaning memory is only freed when an object is unreachable. If a reference to an object remains in a slice, map, or struct, the GC considers it still in use, preventing cleanup.

When This Becomes a Problem

  • Caches or global maps holding large objects indefinitely.
  • Long-lived slices where elements are no longer needed.
  • Structs retaining references to large data structures after they’re done being used.
cache := make(map[string][]byte)
cache["data"] = make([]byte, 1_000_000) // 1MB allocation

// explicitly remove reference to allow GC cleanup
delete(cache, "data")

6. Optimize Struct Memory Layout

Go aligns struct fields to 8-byte boundaries, which can introduce unused padding and waste memory.

// wastes space due to alignment (16 bytes)
type Bad struct {
    b bool  // 1 byte + 7 bytes padding
    x int64 // 8 bytes
}

// uses 9 bytes instead of 16
type Good struct {
    x int64 // 8 bytes
    b bool  // 1 byte (no padding)
}

Best Practices

  1. Group larger fields first to minimize padding.
  2. Use unsafe.Sizeof(struct{}) to check struct size.
  3. Pack smaller fields together for better memory efficiency.

7. Avoid Memory Leaks with Goroutines

Goroutines run independently but don’t automatically stop when they are no longer needed. If not managed properly, they accumulate over time, consuming memory and CPU indefinitely.

// infinite loop, never exits
func leakyGoroutine() {
    go func() {
        for {
            fmt.Println("Running...")
        }
    }()
}

// use select and context to stop goroutines
func safeGoroutine(ctx context.Context) {
    go func() {
        for {
            select {
            case <-ctx.Done(): // exit when context is canceled
                return
            default:
                fmt.Println("Running...")
            }
        }
    }()
}

When This Matters

  • Long-running services (e.g., background workers, server handlers).
  • Short-lived tasks that should clean up after finishing.
  • High-frequency goroutine spawns, where leaks can quickly escalate.

Best Practices

  • Use context.WithCancel to terminate goroutines when they’re no longer needed.
  • Always include an exit condition inside long-running loops.
  • Ensure graceful shutdown by waiting for goroutines to complete before exiting the program.