Go Garbage Collector Basics Every Developer Needs to Know
How Go's concurrent garbage collector decides when to run, what GOGC and GOMEMLIMIT change, how to read gctrace output and the habits that reduce GC work.

You do not call free in Go, and you do pay for that: some of the CPU your program uses and some of the memory it holds belongs to the garbage collector. Most services never need to think about it. A few do, usually because memory is capped by a container or because a latency graph shows a periodic bump. This article explains how the collector decides to run, shows with measurements what GOGC and GOMEMLIMIT change, reads a gctrace line, and ends with the changes in your own code that matter more than any setting. It is part of the Go basics path, builds on stack versus heap and the runtime basics, and pairs with the memory model.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64, Apple M1 Max)
How a collection works, briefly
Go's collector is a concurrent, tri-color, mark-and-sweep collector. A cycle starts, marks the objects reachable from the roots, which are goroutine stacks and global variables, while your goroutines keep running, and then sweeps the unreachable ones back into the allocator, also concurrently. It does not move objects, so pointers do not change. There are two short stop-the-world moments, at the start and end of marking, which are typically well under a millisecond. What a cycle costs you is mostly CPU: it uses a quarter of the available processors by default while marking, and your goroutines are asked to help with the marking work (assists) if they allocate faster than the collector can keep up.
The decision to start a cycle comes from a simple rule. After a collection, the runtime knows how much memory is live. The next cycle begins when the heap has grown by GOGC percent over that: with the default of 100, when the heap doubles. If 10 MB survived, the next cycle triggers near 20 MB. That is why the live heap size, not the total allocation, sets how often the collector runs.
What GOGC and GOMEMLIMIT change
To see the effect, this program keeps about 10 MB of live data and then churns through 400,000 short-lived 1 KB allocations, so the collector has a steady live set and a lot of garbage. It prints how many collections ran:
package main
import (
"fmt"
"runtime"
)
var sink [][]byte
func main() {
live := make([][]byte, 0, 1000)
for i := 0; i < 1000; i++ {
b := make([]byte, 10*1024)
b[0] = 1
live = append(live, b)
}
for i := 0; i < 400_000; i++ {
chunk := make([]byte, 1024)
chunk[0] = byte(i)
sink = append(sink[:0], chunk)
}
total := 0
for _, b := range live {
total += int(b[0])
}
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
fmt.Println("GCs:", ms.NumGC, "heap MB:", ms.HeapAlloc>>20, "live chunks:", total)
}for g in 25 100 400 off; do echo "GOGC=$g"; GOGC=$g go run .; done
echo "GOGC=off GOMEMLIMIT=40MiB"
GOMEMLIMIT=40MiB GOGC=off go run .GOGC=25
GCs: 171 heap MB: 11 live chunks: 1000
GOGC=100
GCs: 41 heap MB: 15 live chunks: 1000
GOGC=400
GCs: 10 heap MB: 34 live chunks: 1000
GOGC=off
GCs: 0 heap MB: 400 live chunks: 1000
GOGC=off GOMEMLIMIT=40MiB
GCs: 17 heap MB: 20 live chunks: 1000The same program, with the same live data and the same garbage, ran 171 collections at GOGC=25, 41 at the default 100, 10 at 400 and none at all with the collector off, where the heap grew to 400 MB because nothing was ever freed. The heap at the end moved the other way: 11 MB at 25, 15 MB at 100 and 34 MB at 400. That is the whole trade in one table: more memory buys fewer cycles, and fewer cycles mean less CPU spent on marking.
The last run is the interesting one. With the percentage trigger switched off, GOMEMLIMIT=40MiB asks the runtime to keep total memory under 40 MiB, and the collector ran 17 times to hold to it, finishing at about 20 MB of heap. GOMEMLIMIT, added in Go 1.19, is a soft limit: the runtime works harder as it approaches the limit and can exceed it if the live data does not fit. The combination GOMEMLIMIT set to a bit below the container's memory limit, with GOGC left at its default, is the usual way to use free memory for speed while avoiding an out-of-memory kill. The older trick of allocating a big dummy "ballast" slice to stop the heap from being too small is no longer needed.
Reading gctrace
Setting GODEBUG=gctrace=1 makes the runtime print one line to standard error per cycle:
GODEBUG=gctrace=1 go run . 2>&1 | head -3gc 1 @0.004s 3%: 0.066+0.39+0.054 ms clock, 0.66+0.24/0.44/0+0.54 ms cpu, 3->4->0 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P
gc 2 @0.007s 3%: 0.039+0.34+0.027 ms clock, 0.39+0.032/0.50/0.36+0.27 ms cpu, 3->3->0 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P
gc 3 @0.017s 2%: 0.11+1.1+0.011 ms clock, 1.1+0.050/1.2/0+0.11 ms cpu, 3->4->1 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 PA typical line looks like gc 1 @0.004s 3%: 0.066+0.39+0.054 ms clock, ... 3->4->0 MB, 4 MB goal, ... 10 P. The pieces that matter:
| Field | Meaning |
|---|---|
gc 1 | Cycle number |
@0.004s | Seconds since the program started |
2% | Share of CPU time spent in the collector since start |
0.066+0.39+0.054 ms clock | Stop-the-world sweep termination, concurrent mark, stop-the-world mark termination |
3->4->0 MB | Heap size when the cycle started, when marking ended, and live after |
4 MB goal | The target heap size the runtime aimed for |
10 P | Processors used |
The two stop-the-world numbers are the pauses your requests could see, here 0.066 and 0.054 ms, a few hundredths of a millisecond. The 3->4->0 MB figure is the one for tuning: the last number is the live heap, and GOGC percent of it sets the goal. A rising share in the % field is the sign that the collector is taking a real fraction of your CPU.
What to change, in order
Tuning comes last. The cheapest reductions in GC work come from allocating less:
- Preallocate slices and maps when you know the size, as slices measured: one allocation in place of twelve.
- Reuse buffers with
sync.Poolfor large short-lived objects, with the cautions in the Pool article. - Avoid heap escape in hot paths: returning values and not pointers, passing pointers down and not up, as returning a value or a pointer measured.
- Reduce pointers in large data structures. The collector scans pointer-containing memory, so a slice of structs with no pointers costs far less to mark than a slice of pointers to structs, and a
map[string]intwith a million entries is mostly scan work for the strings. - Then set
GOMEMLIMITfrom the container limit, and only after that considerGOGC, with a profile in hand.
Measure rather than guess. go test -bench . -benchmem reports allocations per operation, and a heap profile from pprof shows which lines allocate. Stack versus heap covers what makes a value escape.
What the model leaves out
The collector's behavior changes between releases, and the details above are for Go 1.25. Go 1.25 shipped an experimental new collector design, called Green Tea, behind GOEXPERIMENT=greenteagc, and its release notes describe the intent, so check the notes for the release you deploy before assuming the numbers in this article. Some programs need no tuning: a small command line tool exits before the collector matters. Latency-sensitive systems with huge heaps, hundreds of gigabytes, have different problems, mostly mark time proportional to live pointers. And the collector does not know about memory it cannot see, such as memory held by cgo or mapped files, so GOMEMLIMIT does not cover them. The official reference is the Go garbage collector guide, which is the best single page on the subject and includes interactive models of the GOGC trade-off.


