Memory Management & Performance

Difficulty

Go's GC design specifically optimizes for latency, which is a deliberate tradeoff given Go's common use in network services where a long pause directly hurts request latency.

The tri-color mark-and-sweep model

White: not yet visited — candidate for collection
Grey:  visited, but its children haven't been scanned yet
Black: visited, and all its children have been scanned — definitely reachable

The collector starts by marking roots (global variables, goroutine stacks) grey, then repeatedly picks a grey object, scans its pointers, marks anything it points to grey, and marks the object itself black. When no grey objects remain, everything still white is garbage.

Why "concurrent" matters

Old GC approach (older Go versions, and many other languages):
  stop the world -> mark everything -> sweep -> resume

Go's current approach:
  brief stop-the-world (start marking)
  -> CONCURRENT marking, alongside your running goroutines
  -> brief stop-the-world (finish marking)
  -> CONCURRENT sweeping, alongside your running goroutines

Running the mark phase concurrently, with a write barrier to correctly track pointer writes that happen while marking is in progress, is what keeps Go's stop-the-world pauses in the sub-millisecond range, even for multi-gigabyte heaps.

Tuning with GOGC

GOGC=100  # default: trigger GC roughly when heap has doubled since the last collection
GOGC=200  # collect less often, use more memory, generally reduce GC CPU overhead
GOGC=50   # collect more often, use less memory, spend more CPU on GC
GOGC=off  # disable GC entirely (rarely appropriate outside short-lived batch jobs)

The practical takeaway for interviews

Go doesn't require manual memory management, but understanding that GC pauses are brief and concurrent (not the multi-second stop-the-world pauses associated with some older GC designs) is exactly why Go remains viable for low-latency network services, unlike languages whose GC pause times can spike unpredictably under load.

Escape analysis is why Go can often skip heap allocation, and GC involvement, entirely for many local variables.

A variable that stays on the stack

func sum(a, b int) int {
    total := a + b  // never referenced outside sum — stays on the stack
    return total
}

total never outlives sum, so the compiler allocates it on the stack, which is essentially free to allocate and free (just moving a stack pointer), with zero GC involvement.

A variable that escapes to the heap

func newCounter() *int {
    count := 0
    return &count   // count's address escapes — must live on the heap
}

Since newCounter returns a pointer to count, and the caller can use that pointer long after newCounter returns, count can't safely live on a stack frame that's about to be destroyed. The compiler allocates it on the heap instead.

Checking the compiler's actual decisions

go build -gcflags="-m" ./...
./main.go:5:2: total escapes to heap
./main.go:10:9: &count escapes to heap

This flag prints exactly which variables escaped and (often) why, which is the standard way to investigate unexpected allocations in a performance-sensitive hot path.

Common causes of unwanted escapes

func process(v interface{}) { ... }   // passing a concrete value as `any` often forces it to the heap
process(myStruct)                      // myStruct may escape here

func makeClosure() func() int {
    x := 0
    return func() int { x++; return x }  // x escapes: the closure captures and outlives the function
}

Why this matters for performance

Heap allocations cost more than stack allocations, and every heap allocation adds work for the garbage collector later. In a hot loop, an unexpected escape can be a real, measurable performance cost. Passing a value as interface{}, or returning a pointer to a local, are common causes. That's exactly why -gcflags="-m" and profiling are standard tools for optimizing allocation-heavy Go code.

Understanding exactly when append reallocates explains most of the "surprising" slice-sharing bugs people run into.

Appending within capacity: no reallocation

s := make([]int, 3, 5)  // len=3, cap=5
s2 := append(s, 4)       // room available: writes into the same backing array
fmt.Println(&s[0] == &s2[0])  // true — same underlying array

Appending past capacity: reallocation

s := make([]int, 3, 3)  // len=3, cap=3 — no room left
s2 := append(s, 4)       // must allocate a new, larger backing array
fmt.Println(&s[0] == &s2[0])  // false — s2 now points at a completely different array

After this reallocation, s and s2 are fully independent. Modifying one no longer affects the other, which is the opposite of what happens when there's still capacity.

Why this causes subtle bugs

func addItem(items []int, v int) []int {
    return append(items, v)
}

original := make([]int, 2, 5)
a := addItem(original, 10)   // room available: writes into original's backing array
b := addItem(original, 20)   // ALSO writes into the same slot — overwrites a's new element!
fmt.Println(a)  // [0 0 20] — not 10!

Both calls append at index 2 of the same backing array, since original still had spare capacity both times. The second call silently overwrites what the first call wrote, since they never triggered a reallocation to separate them.

Growth strategy, approximately

Small slices (below a runtime-defined threshold): capacity roughly doubles
Larger slices: growth factor decreases toward about 1.25x, to avoid excessive memory waste

The safe pattern when independence matters

result := make([]int, len(original), len(original)+1)
copy(result, original)
result = append(result, v)  // guaranteed to be independent of `original`

Explicitly copying into a right-sized new slice avoids relying on capacity behavior you don't control at the call site.

Aliasing is a direct consequence of how slices work, but it produces bugs that look almost like magic if you don't expect them.

The setup

buf := make([]byte, 10)
a := buf[0:5]
b := buf[5:10]

a[0] = 'X'
fmt.Println(buf[0])  // 'X' — a and buf share the same backing array

This part is usually intuitive. The bug shows up when a slice is reused across iterations and results are saved without copying.

A realistic bug: reusing a buffer across a loop

func readChunks(r io.Reader) [][]byte {
    buf := make([]byte, 4)
    var chunks [][]byte
    for {
        n, err := r.Read(buf)
        if n > 0 {
            chunks = append(chunks, buf[:n])  // BUG: saves a slice aliasing the shared buf
        }
        if err != nil {
            break
        }
    }
    return chunks
}

Every entry in chunks is a sub-slice of the same buf. By the time the function returns, every entry in chunks reflects whatever the last Read call wrote into buf. None of them hold the data actually read at that point in the loop.

The fix: copy before saving

func readChunks(r io.Reader) [][]byte {
    buf := make([]byte, 4)
    var chunks [][]byte
    for {
        n, err := r.Read(buf)
        if n > 0 {
            chunk := make([]byte, n)
            copy(chunk, buf[:n])       // independent copy, safe to keep
            chunks = append(chunks, chunk)
        }
        if err != nil {
            break
        }
    }
    return chunks
}

The general rule

If a sub-slice needs to outlive, or be modified independently of, the slice or buffer it came from, copy it explicitly with copy(). Slicing alone never gives you independence — it only ever gives you a different view into the same data.

The immutability of string is exactly why converting to and from []byte isn't free.

Why a copy is required

s := "hello"
b := []byte(s)   // copies s's bytes into a new, mutable byte slice
b[0] = 'H'
fmt.Println(s)   // still "hello" — s was never touched, because b is an independent copy

If []byte(s) shared memory with s instead of copying, mutating b would silently break s's immutability guarantee, which every other piece of code relying on strings being immutable depends on.

Where this becomes a real performance cost

func buildMessage(parts []string) string {
    var buf []byte
    for _, p := range parts {
        buf = append(buf, []byte(p)...)  // converts and copies on every iteration
    }
    return string(buf)  // another copy, back to string
}

Doing this in a hot loop, especially with many parts or large strings, adds real allocation and copying overhead. The idiomatic fix is strings.Builder, which avoids intermediate []byte conversions entirely:

func buildMessage(parts []string) string {
    var b strings.Builder
    for _, p := range parts {
        b.WriteString(p)   // no conversion, writes directly into an internal buffer
    }
    return b.String()
}

The one place the compiler skips the copy

m := map[string]int{"key": 1}
b := []byte("key")
v := m[string(b)]   // compiler special-cases this: no allocation for the conversion

The compiler recognizes string(b) used directly as a map key lookup and avoids the allocation, since the converted string never needs to outlive the lookup itself. This optimization is narrow and specific — general conversions elsewhere in your code still copy.

The practical guidance

Avoid unnecessary string/[]byte round-trips in hot paths. Prefer strings.Builder or bytes.Buffer for building up data incrementally, and reach for the standard library's optimized helpers (like strconv functions) instead of manual conversions where possible.