Garbage Collection

Updated

September 13, 2026

Garbage Collection

Go’s garbage collector frees heap memory you no longer reference. It is not malloc/free with a different name. The boring default is: allocate normally, drop references when you are done, and reuse buffers only after a measurement says allocation is the hot path.

Mental model

You allocate by creating values the compiler cannot keep on the stack: &Ticket{...}, make([]T, n), growing append, converting to any, and so on. You do not free. When no pointer can reach a value, the GC may reclaim it.

GOGC is the target heap growth, as a percent. 100 means “run a collection after the heap grows about 100% since the last one.” Higher GOGC: fewer collections, more memory. Lower GOGC: more collections, a smaller heap. GOGC=off turns the collector off — a debug trick, not a production plan.

runtime.ReadMemStats snapshots counters. They move a little between runs. Use them to see direction (ten thousand tickets appeared; then they left), not to assert an exact byte count in a book.

Worked examples

Case 1: Live pointers keep objects; dropping them lets GC run

Save as tickets_gc.go. Ten thousand tickets stay alive because held points at them. Setting held = nil drops the only reference. runtime.GC() asks for a collection now so the numbers are readable. You rarely call that in a service.

// tickets_gc.go
package main

import (
    "fmt"
    "runtime"
)

type Ticket struct {
    ID    int
    Table int
    Note  string
}

func main() {
    runtime.GC()
    var before runtime.MemStats
    runtime.ReadMemStats(&before)

    held := make([]*Ticket, 0, 10_000)
    for i := range 10_000 {
        held = append(held, &Ticket{ID: i, Table: i % 20, Note: "soup"})
    }

    var after runtime.MemStats
    runtime.ReadMemStats(&after)
    fmt.Printf("tickets: %d\n", len(held))
    fmt.Printf("heap objects gained: %d\n", after.HeapObjects-before.HeapObjects)

    held = nil
    runtime.GC()
    var done runtime.MemStats
    runtime.ReadMemStats(&done)
    fmt.Printf("gc runs while held: %d\n", after.NumGC-before.NumGC)
    fmt.Printf("gc runs after drop: %d\n", done.NumGC-after.NumGC)
}

Run:

go run tickets_gc.go

Output (object counts can shift by a few on another machine; the 10000 tickets are the point):

tickets: 10000
heap objects gained: 10001
gc runs while held: 0
gc runs after drop: 1

While held was live, the collector did not need to run. After the drop, one GC pass ran and the extra objects were eligible. You did not call free.

Case 2: GOGC is a dial, not a rewrite

Save as gogc.go. debug.SetGCPercent returns the previous percent. The environment variable GOGC sets the starting value (100 if unset).

// gogc.go
package main

import (
    "fmt"
    "runtime/debug"
)

func main() {
    prev := debug.SetGCPercent(200)
    fmt.Println("previous GOGC:", prev)
    now := debug.SetGCPercent(200)
    fmt.Println("current GOGC:", now)
}

Run:

go run gogc.go

Output:

previous GOGC: 100
current GOGC: 200

Run with a tighter target:

GOGC=50 go run gogc.go

Output:

previous GOGC: 50
current GOGC: 200

Leave GOGC at the default until a memory limit or a latency graph says otherwise. GOMEMLIMIT (not shown here) is the modern partner for “do not use more than this.” Do not sprinkle SetGCPercent inside request handlers.

Case 3: Reuse a slice when a profile says so

Save as reuse.go. Filling a fresh slice 50 times allocates. Reusing one backing array with buf = buf[:0] does not. The numbers below are malloc counts from MemStats, not a promise for every Go version.

// reuse.go
package main

import (
    "fmt"
    "runtime"
)

func fill(buf []int, n int) []int {
    buf = buf[:0]
    for i := range n {
        buf = append(buf, i)
    }
    return buf
}

func allocEach(n, times int) uint64 {
    runtime.GC()
    var before runtime.MemStats
    runtime.ReadMemStats(&before)
    for range times {
        _ = fill(nil, n)
    }
    var after runtime.MemStats
    runtime.ReadMemStats(&after)
    return after.Mallocs - before.Mallocs
}

func reuse(n, times int) uint64 {
    runtime.GC()
    var before runtime.MemStats
    runtime.ReadMemStats(&before)
    buf := make([]int, 0, n)
    for range times {
        buf = fill(buf, n)
    }
    var after runtime.MemStats
    runtime.ReadMemStats(&after)
    _ = buf
    return after.Mallocs - before.Mallocs
}

func main() {
    fmt.Println("fresh slices mallocs:", allocEach(1000, 50))
    fmt.Println("reused slice mallocs:", reuse(1000, 50))
}

Run:

go run reuse.go

Output:

fresh slices mallocs: 450
reused slice mallocs: 1

Reuse won because we measured a tight loop. A desk program that builds one order list per request does not need this. sync.Pool is the same idea with concurrency; skip it until a benchmark names allocation as the cost.

Case 4: Setting a memory ceiling with GOMEMLIMIT

Save as memlimit.go. In containerized environments, Go services historically risked getting killed by the operating system OOM killer because GOGC only knows about the relative growth of the live heap, not the container’s hard memory boundary. GOMEMLIMIT sets a soft ceiling: when total memory approaches this limit, the Go runtime triggers garbage collection proactively.

// memlimit.go
package main

import (
    "fmt"
    "runtime/debug"
)

func main() {
    // Query current limit (-1 queries without changing)
    prev := debug.SetMemoryLimit(-1)
    fmt.Println("initial limit no-limit:", prev < 0 || prev > 1<<60)

    // Set soft memory limit to 128 MiB
    limit128MiB := int64(128 * 1024 * 1024)
    debug.SetMemoryLimit(limit128MiB)
    current := debug.SetMemoryLimit(-1)
    fmt.Printf("current limit: %d MiB\n", current/(1024*1024))
}

Run:

go run memlimit.go

Output:

initial limit no-limit: true
current limit: 128 MiB

You can set this in code with debug.SetMemoryLimit, or externally via the environment variable GOMEMLIMIT=128MiB. Set it to ~90% of your container’s memory limit to provide headroom for the runtime and OS.

The trap

Trap 1: Subslice memory retention

When you slice a small header from a large buffer or payload, the subslice retains a pointer to the entire original backing array. The garbage collector cannot free that array as long as your subslice is reachable.

Save as subslice_leak.go:

// subslice_leak.go
package main

import (
    "fmt"
    "slices"
)

func main() {
    big := make([]byte, 10*1024*1024) // 10 MiB payload
    leaky := big[:4]
    clean := slices.Clone(big[:4])

    fmt.Printf("leaky len=%d cap=%d bytes pinned\n", len(leaky), cap(leaky))
    fmt.Printf("clean len=%d cap=%d bytes pinned\n", len(clean), cap(clean))
}

Run:

go run subslice_leak.go

Output:

leaky len=4 cap=10485760 bytes pinned
clean len=4 cap=8 bytes pinned

leaky pinned all 10 megabytes of memory for a 4-byte slice. slices.Clone (or copy) copied the 4 bytes into an independent backing array, allowing the 10 MiB payload to be freed.

Trap 2: Treating GC like manual malloc

Treating GC like a poorly hidden malloc leads to two bad desks: either you never allocate (unreadable code, buffer pools for a 20-line tool) or you runtime.GC() after every ticket “to keep memory tidy.” The second makes the process stall for no reason. The first makes every change a puzzle.

The collector is part of the language. Write clear code. Keep references only while you need the values. Measure. Then reuse.

The boring rule

  • Drop the last pointer when you are done (held = nil, end of function, clear a map). That is the free.
  • Do not call runtime.GC() in normal request paths.
  • Leave GOGC alone until you have a memory or pause graph.
  • Set GOMEMLIMIT in container environments to prevent unexpected OOM kills.
  • Use slices.Clone or copy when keeping a tiny subslice from a large buffer, so the large buffer can be reclaimed.
  • Reuse slices (s = s[:0]) and buffers after a benchmark, not after a blog post.
  • MemStats is a snapshot. Log it in a debug endpoint if you must; do not unit-test exact Alloc values.

Try this

  1. In tickets_gc.go, comment out held = nil and keep runtime.GC(). How does gc runs after drop change? The tickets are still reachable.
  2. In subslice_leak.go, replace slices.Clone with manual copy into a make([]byte, 4) slice. Verify cap is still tiny.
  3. Run GOMEMLIMIT=64MiB go run memlimit.go and inspect the initial limit reported before modification.
  4. Run GOGC=off go run tickets_gc.go. Read about GOGC=off in go doc runtime. Do not ship that.
  5. In reuse.go, change fill(nil, n) to reuse a slice the same way reuse does. The two printed numbers should get close.