The garbage collector and GOGC

Go · Expert · 15 min

GC behaviour sets your service's CPU overhead, memory footprint and tail latency.

Go uses a concurrent, tri-colour, mark-and-sweep collector. It is non-generational and non-moving: objects never relocate, which keeps pointers stable and makes interop simpler. Most marking runs concurrently with your code; only two short stop-the-world phases bracket it, usually well under a millisecond. During marking a write barrier records pointer updates so concurrently running code can't hide a live object.

When does a cycle start? The heap goal is controlled by GOGC (default 100): after a collection, the next one triggers when the heap grows by GOGC% of the live heap (plus GC roots such as stacks and globals). With 100 MB live, the next GC targets about 200 MB.

  • Higher GOGC → fewer cycles, less CPU, more memory. GOGC=off disables collection.
  • GOMEMLIMIT (Go 1.19+) is a soft memory limit: as total runtime memory approaches it, the GC runs more often regardless of GOGC. Setting it a bit below a container's memory limit, often combined with a higher GOGC, avoids OOM kills while keeping GC cheap.
  • Both can be set from code via runtime/debug.SetGCPercent and SetMemoryLimit.

If allocation outpaces marking, the runtime makes allocating goroutines do mark assist work, which shows up as latency. GC cost scales mainly with the amount of live, pointer-containing memory and the allocation rate — so reducing allocations is the most effective tuning.

Observe it with GODEBUG=gctrace=1 (one line per cycle), runtime.ReadMemStats, the runtime/metrics package, and the GC lanes in go tool trace. Go 1.24 added runtime.AddCleanup and the weak package as better alternatives to runtime.SetFinalizer; Go 1.25 shipped the experimental "Green Tea" collector improving marking locality.

Example

package main

import (
	"fmt"
	"runtime"
	"runtime/debug"
)

var sink [][]byte

func main() {
	debug.SetGCPercent(200)         // like GOGC=200
	debug.SetMemoryLimit(512 << 20) // like GOMEMLIMIT=512MiB

	for range 1000 {
		sink = append(sink, make([]byte, 64<<10)) // ~6 MiB stays live
		if len(sink) > 100 {
			sink = sink[1:] // older buffers become garbage
		}
	}

	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("heap=%d MiB cycles=%d\n", m.HeapAlloc>>20, m.NumGC)
}

Remember

  • Concurrent, non-moving, non-generational mark-sweep with a write barrier
  • GOGC trades CPU for memory; GOMEMLIMIT caps memory softly
  • Fewer allocations and less live pointer data = cheaper GC

Watch out for

  • Setting GOMEMLIMIT below the program's actual live heap — the GC runs continuously and the program crawls (thrashing).

Check yourself

  1. Live heap is 50 MB and GOGC=100. Roughly when will the next collection be triggered?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: go.dev/doc/gc-guide

Builds on

Unlocks

Nothing builds on this yet.