Understanding How Go's Garbage Collector Works
17 min read

Understanding How Go's Garbage Collector Works

Most Go developers write code without ever explicitly thinking about the GC — and that’s a deliberate design choice by the Go team. But there are moments when understanding how the GC works becomes non-optional: when an application experiences unexplained latency spikes, when memory usage keeps rising despite no obvious memory leak, or when profiling shows the GC eating a disproportionate percentage of CPU. At that point, the GC is no longer an implementation detail you can ignore — it’s a variable you must understand to make the right decisions. This article discusses how Go’s GC works from the algorithm level to the practical level: how it finds objects no longer needed, how it avoids long stop-the-world pauses, what makes it work hard, and how to write code that works harmoniously with it.

Why Go Chose Tracing GC

There are two major approaches in automatic memory management: reference counting and tracing garbage collection. Python and Swift use reference counting — every object stores a count of how many references point to it, and when the count reaches zero, the object is immediately freed. Go chose tracing GC for very concrete reasons.

Reference counting has two fundamental problems that are hard to solve. First, it can’t detect reference cycles — two objects pointing at each other will never reach a count of zero even if both are unreachable from the program. This forces a separate cycle detector mechanism that adds complexity. Second, every reference update must be atomic for thread safety, adding overhead to every assignment — an invisible per-operation cost that’s significant in aggregate in programs with many goroutines.

Tracing GC solves both problems: it can naturally detect cycles because it walks from the roots, and there’s no per-assignment overhead for counting references. The trade-off is that the GC needs time to run periodically — and this is what determines Go’s latency characteristics.


Tri-Color Mark-and-Sweep: The Main Algorithm

Go’s GC uses the tri-color mark-and-sweep algorithm, designed to run concurrently with application goroutines. Understanding these three colors is the key to understanding the entire GC mechanism.

The Three Colors and Their Meanings

Every object in the Go heap is, at any moment, in one of three colors:

White — objects not yet examined by the GC. At the start of a GC cycle, all objects are white. At the end of the cycle, objects still white are objects unreachable from the roots — they are the garbage to be swept.

Gray — objects already discovered by the GC (reachable from the roots), but whose references haven’t been fully examined yet. Gray objects are the GC’s “work queue”.

Black — objects fully examined along with all their references. Black objects are guaranteed still alive and won’t be touched again this cycle.

stateDiagram-v2
    [*] --> White: Start of GC cycle<br/>All objects start white

    White --> Gray: Found from a root<br/>or from a gray object
    Gray --> Black: All references<br/>already examined

    White --> Freed: End of cycle<br/>White objects = garbage
    Black --> White: Start of next cycle<br/>Reset everything to white

    note right of Gray: GC worklist<br/>Waiting for its references<br/>to be examined
    note right of Black: Guaranteed still alive<br/>Cannot point to<br/>a white object

The Invariant That Guarantees Correctness

The tri-color algorithm works on one invariant that must never be violated: a black object must never have a direct reference to a white object. If this invariant holds, then at the end of the marking phase, all remaining white objects are certainly unreachable from the roots and safe to free.

This invariant is what allows concurrent GC to run alongside application goroutines — but it’s also what makes the write barrier a necessity.

The Phases in One GC Cycle

sequenceDiagram
    participant App as Application Goroutine
    participant GC as GC Goroutine
    participant Heap as Heap Memory

    Note over App,Heap: Phase 1: Mark Setup (Stop-The-World, very brief)
    GC->>App: STW — pause all goroutines
    GC->>GC: Activate write barrier
    GC->>GC: Scan roots (stacks, globals, heap pointers)
    GC->>App: Resume — goroutines run again

    Note over App,Heap: Phase 2: Concurrent Mark (runs alongside the application)
    GC->>Heap: Scan gray objects, find references to white
    GC->>Heap: Color discovered white objects gray
    GC->>Heap: Color fully scanned gray objects black
    App->>Heap: Application goroutines keep running and allocating
    App->>GC: Write barrier reports pointer changes to the GC

    Note over App,Heap: Phase 3: Mark Termination (Stop-The-World, very brief)
    GC->>App: STW — pause all goroutines
    GC->>GC: Drain the remaining gray worklist
    GC->>GC: Deactivate write barrier
    GC->>App: Resume

    Note over App,Heap: Phase 4: Concurrent Sweep (runs alongside the application)
    GC->>Heap: Free all spans containing only white objects
    App->>Heap: Application goroutines keep running normally

The most important thing about this diagram: stop-the-world (STW) only happens twice and is very brief — usually under 1 millisecond for a well-configured program. Most of the GC work (marking and sweeping) runs concurrently.


Write Barrier: Keeping the Invariant Safe While Concurrent

The write barrier is the most often misunderstood mechanism in Go’s GC. Many developers know the write barrier “exists” but don’t understand why it’s needed.

The Problem the Write Barrier Solves

Without a write barrier, the following scenario could break the tri-color invariant:

sequenceDiagram
    participant App as Application Goroutine
    participant GC as GC (marking in progress)

    Note over App,GC: Condition: A (black), B (gray), C (white)
    Note over App,GC: GC already finished marking A — A won't be examined again

    App->>App: A.ref = C  (black object A now points to white C)
    App->>App: B.ref = nil (gray object B drops its reference to C)

    Note over GC: GC processes B — doesn't find C
    Note over GC: C is never marked gray
    Note over GC: End of cycle: C is still white → considered garbage!
    Note over GC: BUG: C is still used by A but has already been freed

Without a write barrier, the GC could free an object still actively in use — this is a very serious use-after-free bug.

How the Write Barrier Works

Go uses a hybrid write barrier (since Go 1.17) that captures two kinds of operations:

When a new pointer is written — if an application goroutine writes a pointer to a memory slot, the write barrier ensures the pointed-to object enters the GC’s gray worklist.

When an old pointer is removed — the old value of the memory slot is also added to the gray worklist, ensuring a released object doesn’t disappear before the GC gets a chance to examine it.

// What you see in Go code:
a.field = c

// What actually happens while the write barrier is active:
// (pseudocode — this is implemented at the runtime level, not regular Go code)
//
// oldValue := a.field   // save the old value
// shade(oldValue)        // add the old value to the gray worklist
// a.field = c            // do the assignment
// shade(c)               // add the new value to the gray worklist

The write barrier is only active during the marking phase. This has an important consequence: there’s a small overhead on every pointer write while the GC is running, but no overhead at all between GC cycles. Unlike reference counting, which always has overhead.

The write barrier only applies to pointers stored on the heap — pointers on goroutine stacks are handled separately with a cheaper method because stacks are scanned during the STW mark setup. This is one reason why allocating on the stack (rather than the heap) is more efficient in a GC context.

Escape Analysis: What Goes to the Heap

Before discussing further how the GC manages the heap, it’s important to understand what decides whether an allocation goes to the stack or the heap. This decision is made by the Go compiler through a process called escape analysis.

Objects allocated on the stack are much cheaper: they don’t need to be tracked by the GC, are freed automatically when the function returns, and add no pressure to the GC. Objects that “escape” to the heap are objects that must be tracked by the GC.

// ✓ Doesn't escape to the heap — allocated on the stack
func sum(a, b int) int {
    result := a + b  // result is on the stack, gone when the function returns
    return result
}

// ✓ Doesn't escape — the compiler can prove the pointer doesn't leave
func process() {
    x := 42
    helper(&x)  // if helper doesn't store the pointer to x, x stays on the stack
}

// ✗ Escapes to the heap — the pointer is returned, accessible after the function returns
func newUser(name string) *User {
    u := User{Name: name}  // u must be on the heap because its pointer leaves the function
    return &u
}

// ✗ Escapes to the heap — stored in an interface{}
func store(v interface{}) {
    cache[key] = v  // the concrete value is boxed into the interface, goes to the heap
}

// ✗ Escapes to the heap — size unknown at compile time
func makeSlice(n int) []int {
    return make([]int, n)  // n is dynamic, can't be on the stack
}

You can see what the compiler escapes with the -gcflags="-m" flag:

# See the escape analysis for one file
go build -gcflags="-m" ./main.go

# Example output:
# ./main.go:15:6: moved to heap: u
# ./main.go:22:14: make([]int, n) escapes to heap
# ./main.go:8:14: result does not escape

Understanding escape analysis helps you write code that produces fewer heap allocations — which means less pressure on the GC.


Triggering GC: When and Why

Go doesn’t run the GC on a fixed time interval. The GC is triggered based on heap growth, governed by a controllable parameter.

GOGC: The Heap Growth Target Ratio

The GOGC parameter (default 100) defines the percentage of heap growth allowed before the next GC is triggered. With GOGC=100, the GC will run when the current heap size reaches twice the size of the live heap left after the previous GC.

flowchart LR
    GC1["GC Finished<br/>Live heap: 50 MB"] --> CALC["Next GC target:<br/>50 MB × (1 + GOGC/100)<br/>= 50 MB × 2<br/>= 100 MB"]
    CALC --> ALLOC["Application allocates<br/>new memory..."]
    ALLOC --> TRIGGER{"Heap reaches<br/>100 MB?"}
    TRIGGER -- Yes --> GC2["Next GC<br/>triggered"]
    TRIGGER -- No --> ALLOC
    GC2 --> GC1

Consequences of this formula:

  • Higher GOGC (e.g. 200) → GC triggered less often, lower GC CPU overhead, but higher memory usage
  • Lower GOGC (e.g. 50) → GC triggered more often, smaller memory footprint, but higher GC CPU overhead
  • GOGC=off → GC never triggered automatically (only triggerable manually with runtime.GC())

GOMEMLIMIT: A Safer Upper Bound (Go 1.19+)

GOMEMLIMIT is a newer parameter and very useful for container deployments. It defines the upper bound of memory the Go runtime may use — including heap, stacks, and runtime overhead.

flowchart TD
    ALLOC[Application allocates memory] --> CHECK{Heap approaching<br/>GOMEMLIMIT?}
    CHECK -- No --> NORMAL[Continue normally<br/>GOGC controls GC frequency]
    CHECK -- Yes --> AGGRESSIVE[GC runs more aggressively<br/>even though the GOGC target isn't reached]
    AGGRESSIVE --> FREED{Enough memory<br/>freed?}
    FREED -- Yes --> NORMAL
    FREED -- No --> OOM[OOM — limit already reached<br/>runtime can't do more]

The combination of GOGC and GOMEMLIMIT gives more precise control:

# In a container with a 512 MB limit, set GOMEMLIMIT slightly below the container limit
# so the GC can work before the container gets OOM-killed
GOMEMLIMIT=450MiB go run main.go

# Or via code:
import "runtime/debug"
debug.SetMemoryLimit(450 * 1024 * 1024)
// Example: configuring GC via code for a microservice in a container
func init() {
    // Set GOMEMLIMIT to 90% of the container memory limit
    // to provide a buffer before the OOM killer activates
    containerMemLimit := int64(512 * 1024 * 1024) // 512 MB
    debug.SetMemoryLimit(int64(float64(containerMemLimit) * 0.9))

    // GOGC=100 is a reasonable default for most services
    // Raise it if GC CPU overhead is too high
    // Lower it if the memory footprint needs to shrink
    debug.SetGCPercent(100)
}
Don’t set GOMEMLIMIT to exactly the container memory limit. The GC needs a little room to work — if the heap is already at the limit when GC starts, the GC has no room for the temporary allocations needed during marking. Use 85–90% of the container limit as a safe value.

Reading the GC Trace

Before doing any tuning, you need to be able to read the signals the GC gives. GODEBUG=gctrace=1 is the most direct way to see what the GC is doing.

GODEBUG=gctrace=1 go run main.go

# Example output:
# gc 1 @0.012s 3%: 0.024+2.1+0.018 ms clock, 0.19+0.44/2.0/0+0.14 ms cpu,
#    4->4->2 MB, 5 MB goal, 0 MB stacks, 0 MB globals, 8 P

This output format looks cryptic but is very informative once understood:

gc 1        → GC cycle number (cumulative since program start)
@0.012s     → time since program start
3%          → percentage of CPU time spent in GC (ideally < 5%)

0.024       → STW mark setup duration (milliseconds)
2.1         → concurrent mark duration (milliseconds)
0.018       → STW mark termination duration (milliseconds)

4->4->2 MB  → heap size: before GC → after marking → after sweep
5 MB goal   → target heap size for the next GC (based on GOGC)
8 P         → number of processors (GOMAXPROCS)
// A simple program to observe the GC trace
package main

import (
    "fmt"
    "runtime"
    "time"
)

func allocate() []byte {
    // An allocation that will be GC'd
    return make([]byte, 1*1024*1024) // 1 MB per call
}

func main() {
    var slices [][]byte

    for i := 0; i < 20; i++ {
        s := allocate()
        slices = append(slices, s)

        if i%5 == 4 {
            // Drop the references so the GC can work
            slices = nil
        }

        var stats runtime.MemStats
        runtime.ReadMemStats(&stats)
        fmt.Printf("Iteration %2d: Heap=%5d KB, NumGC=%d\n",
            i, stats.HeapAlloc/1024, stats.NumGC)

        time.Sleep(100 * time.Millisecond)
    }
}

Run it with GODEBUG=gctrace=1 go run main.go and observe the GC patterns that appear.


GC-Friendly Code Patterns

Understanding the GC lets you write more efficient code — not by avoiding allocations entirely (which is often impossible), but by creating allocation patterns that are easier for the GC to manage.

Use sync.Pool for Short-Lived Objects

sync.Pool is a mechanism for reusing objects that are frequently created and discarded. The GC understands pools specially — objects in a pool can be freed when the GC runs but aren’t treated as garbage that “needs to be freed immediately”.

import (
    "bytes"
    "sync"
)

// CORRECT: a pool for buffers frequently used and discarded
var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func processRequest(data []byte) string {
    // Get from the pool — reuse if available, create new if not
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset() // important: reset before use
    defer bufPool.Put(buf) // return to the pool when done

    buf.Write(data)
    buf.WriteString(" processed")
    return buf.String()
}

// ANTI-PATTERN: creating a new buffer for every request
func processRequestNaive(data []byte) string {
    buf := new(bytes.Buffer) // ✗ new allocation every call
    buf.Write(data)
    buf.WriteString(" processed")
    return buf.String()
    // buf is GC'd after the function returns — high GC pressure with many requests
}

Minimize Allocations in Hot Paths

Allocations in hot paths — code executed thousands or millions of times per second — are the main contributors to GC pressure. Some patterns to reduce them:

// ANTI-PATTERN: allocating a new slice for every filter operation
func filterActiveUsers(users []User) []User {
    result := []User{} // ✗ new allocation every call
    for _, u := range users {
        if u.Active {
            result = append(result, u)
        }
    }
    return result
}

// CORRECT: pre-allocate with an estimated capacity
func filterActiveUsers(users []User) []User {
    result := make([]User, 0, len(users)) // ✓ pre-allocated, no reallocation needed
    for _, u := range users {
        if u.Active {
            result = append(result, u)
        }
    }
    return result
}

// BETTER: use an existing slice as the destination (zero allocation)
func filterActiveUsersInto(users []User, dst []User) []User {
    dst = dst[:0] // reset length but keep capacity
    for _, u := range users {
        if u.Active {
            dst = append(dst, u)
        }
    }
    return dst
}
// ANTI-PATTERN: unnecessary string-to-[]byte conversions
func containsKeyword(content string, keyword string) bool {
    return bytes.Contains([]byte(content), []byte(keyword)) // ✗ two allocations per call
}

// CORRECT: use the strings package, which doesn't need allocations
func containsKeyword(content string, keyword string) bool {
    return strings.Contains(content, keyword) // ✓ zero allocation
}

Avoid Unnecessary Pointers for Small Structs

Pointers always go to the heap — even for very small structs. For small structs created frequently, values (not pointers) can be more efficient because they can be allocated on the stack.

// ANTI-PATTERN: pointers to small structs that don't need to outlive the function
type Point struct {
    X, Y float64
}

func computeDistance(p1, p2 *Point) float64 { // ✗ both points on the heap
    dx := p2.X - p1.X
    dy := p2.Y - p1.Y
    return math.Sqrt(dx*dx + dy*dy)
}

// CORRECT: values, not pointers — the compiler can place them on the stack
func computeDistance(p1, p2 Point) float64 { // ✓ p1 and p2 on the stack
    dx := p2.X - p1.X
    dy := p2.Y - p1.Y
    return math.Sqrt(dx*dx + dy*dy)
}

Use Slice of Struct, Not Slice of Pointer

One of the most influential patterns for GC performance, but often unnoticed, is the difference between []T and []*T.

// ANTI-PATTERN: slice of pointers — the GC must scan every pointer
type Record struct {
    ID   int64
    Data [256]byte
}

records := make([]*Record, 1000) // ✗ 1000 pointers → 1000 separate objects on the heap
for i := range records {
    records[i] = &Record{ID: int64(i)}
}
// The GC must follow 1000 pointers to check whether each Record is still alive

// CORRECT: slice of values — the GC only needs to scan one object
records := make([]Record, 1000) // ✓ one allocation, one object on the heap
for i := range records {
    records[i].ID = int64(i)
}
// The GC only needs to examine one contiguous memory span containing all Records

This difference is very significant for programs with many small objects in large collections. []T gives the GC far less work because all data is stored contiguously in memory.


Using pprof for GC Investigation

GODEBUG=gctrace=1 provides high-level signals, but for deeper investigation you need pprof.

import (
    "net/http"
    _ "net/http/pprof" // import this for its side effect — registers HTTP handlers
    "runtime"
)

func main() {
    // Enable the pprof endpoint on a separate port
    go func() {
        http.ListenAndServe("localhost:6060", nil)
    }()

    // ... application code
}
# Take a heap profile — a snapshot of current allocations
go tool pprof http://localhost:6060/debug/pprof/heap

# Take an allocation profile — all allocations since the program started
go tool pprof http://localhost:6060/debug/pprof/allocs

# Inside the pprof interactive shell:
# top     → show the functions with the largest allocations
# list    → show source code with allocation annotations
# web     → open a graph in the browser (needs graphviz)

# Compare two snapshots to find a memory leak
go tool pprof -base heap1.pb.gz heap2.pb.gz
# Benchmark with allocation measurement — very useful for hot paths
go test -bench=. -benchmem ./...

# Example output:
# BenchmarkProcessRequest-8   1000000   1234 ns/op   256 B/op   3 allocs/op
# ─────────────────────────────────────────────────────────────────────────
# 256 B/op   → bytes allocated per operation
# 3 allocs/op → heap allocations per operation
# Goal: reduce both numbers for hot paths

When GC Tuning Is Needed and When It Isn’t

Knowing when to tune is as important as knowing how. Prematurely tuning GC parameters can hide real problems or make the system less stable.

GC TUNING IS NEEDED if:
  ✓ The GC consistently uses > 5% CPU (see from gctrace)
  ✓ GC pauses (STW) > 1ms and affect the latency SLA
  ✓ The memory footprint is larger than expected
  ✓ Throughput drops as load rises because of GC thrashing
  ✓ Already verified via profiling that the GC is a real bottleneck

DON'T TUNE THE GC if:
  ✗ Performance hasn't been measured — profile first before touching GOGC
  ✗ The real problem is in the algorithm or allocation patterns
  ✗ Only based on "feels slow" without concrete data
  ✗ The application already has healthy memory usage and the GC is < 3% CPU

THE CORRECT TUNING STEPS:
  1. Measure with GODEBUG=gctrace=1 during load testing
  2. Profile with pprof to find the largest allocation sources
  3. Fix allocation patterns in code (sync.Pool, pre-alloc, struct layout)
  4. Measure again — compare before and after
  5. If still needed: adjust GOGC or GOMEMLIMIT
  6. Document the reason and chosen values

Summary

  • Go uses tri-color concurrent mark-and-sweep — three colors (white, gray, black) allow the GC to run alongside application goroutines with only two very brief STW pauses.
  • The write barrier maintains the “black objects don’t point to white objects” invariant — it’s active during the marking phase and adds a small overhead per pointer write, but no overhead between GC cycles.
  • Escape analysis decides whether an allocation goes to the stack or heap — stack objects are free from the GC’s perspective; minimize heap escapes by avoiding unnecessary pointers and dynamic sizes in critical functions.
  • GOGC controls the CPU vs memory trade-off — higher values mean GC runs less often (lower CPU, higher memory); lower values mean the opposite. The default of 100 suits most applications.
  • GOMEMLIMIT is the modern way for container deployments — set it to 85–90% of the container memory limit to prevent OOM kills before the GC can work.
  • sync.Pool is the most effective tool for reducing GC pressure — use it for short-lived objects frequently created and discarded in hot paths like buffers and parsers.
  • Slice of struct is better than slice of pointer for large collections[]T is one object on the heap; []*T is N+1 objects each needing GC tracing.
  • Profile before tuningGODEBUG=gctrace=1 to see GC frequency and duration, pprof to find the largest allocation sources. Don’t touch GOGC before there’s concrete data showing the GC is the bottleneck.

Portfolio