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.
Related Resources
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.
Related Resources
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.
Related Resources
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.
Related Resources
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.