How to Tune the Go Garbage Collector with GOGC

Tune Go garbage collection frequency by setting the GOGC environment variable to a percentage target.

When memory usage climbs and latency spikes

You deploy a Go service. It handles requests smoothly for the first few hours. Then the container hits its memory limit and the orchestrator kills it. You check the code. There are no leaks. The allocator is just holding onto memory because the garbage collector hasn't run in a while.

Or the opposite happens. Your p99 latency shows a periodic spike every 45 seconds. The service is thrashing because the collector runs too often, fighting to keep the heap small while your workload allocates aggressively.

GOGC is the knob that balances this trade-off. It controls how much the heap is allowed to grow relative to live data before the collector kicks in. Tuning it changes the shape of your memory usage and the frequency of CPU pauses.

The GOGC lever

GOGC stands for Go Garbage Collection. It is a percentage value. The runtime tracks the amount of memory your program uses for live objects. When the total heap size grows by that percentage compared to the live data, the collector starts a cycle.

The default value is 100. This means the collector triggers when the heap doubles. If your program has 100 MB of live data, the GC runs when the heap reaches 200 MB. After the cycle, the heap shrinks back toward 100 MB as dead objects are reclaimed.

A value of 200 means the heap can grow to 300 MB before GC runs. You get fewer collections, lower CPU overhead, but higher peak memory usage. A value of 50 means the heap triggers at 150 MB. You get more collections, lower memory usage, but higher CPU overhead.

Think of GOGC like the fill-line on a trash can. A low line means you empty the can often. You keep the room tidy, but you spend more time walking to the dumpster. A high line means you let trash pile up. You walk less often, but the room gets messy and the can eventually overflows.

How the runtime calculates the trigger

The runtime maintains a counter for allocated memory. It also estimates the size of live objects based on the last collection. The trigger point is calculated as:

next_gc = heap_live * (GOGC / 100)

When heap_alloc crosses next_gc, the collector starts. The collector marks reachable objects, sweeps unreachable ones, and updates the live estimate. The heap size drops. The cycle repeats.

This creates a sawtooth pattern in memory graphs. The heap grows linearly as allocations happen, then drops sharply when GC runs. The slope of the growth depends on your allocation rate. The height of the peak depends on GOGC.

Changing GOGC changes the height of the peaks and the frequency of the drops. It does not change the allocation rate. It does not change the amount of live data. It only changes when the cleanup happens.

Tuning from code

The environment variable GOGC sets the initial value at startup. You can also change it at runtime using debug.SetGCPercent. This function returns the previous value, which follows the Go convention of returning old state when mutating global configuration.

Here's how you adjust the threshold programmatically. The environment variable sets the startup value; debug.SetGCPercent lets you tweak it while the program runs.

package main

import (
	"fmt"
	"runtime/debug"
)

// AdjustGOGC demonstrates changing the GC threshold at runtime.
func AdjustGOGC() {
	// debug.SetGCPercent changes the target heap growth percentage.
	// A value of 200 delays GC until the heap is 3x the live data size.
	// It returns the previous setting so you can restore it later.
	previous := debug.SetGCPercent(200)

	fmt.Printf("Changed GOGC from %d to 200\n", previous)

	// Restore the original value when done.
	// This prevents permanent side effects in long-running processes.
	debug.SetGCPercent(previous)
}

Use debug.SetGCPercent when you need to adapt to changing load patterns. For example, you might increase GOGC during a burst of allocations to reduce GC pressure, then lower it when the burst ends to reclaim memory.

Measuring the impact

Tuning without metrics is guessing. Use runtime.MemStats to see what the collector is actually doing. This struct gives you the numbers you need to decide if tuning is necessary.

package main

import (
	"fmt"
	"runtime"
)

// InspectMemory prints current heap usage and GC activity.
func InspectMemory() {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)

	// Alloc is current heap usage in bytes.
	// HeapAlloc tracks live objects plus temporary allocations.
	fmt.Printf("HeapAlloc: %d MB\n", m.Alloc/1024/1024)

	// NumGC counts how many collections have run.
	// A rapidly increasing counter suggests frequent GC cycles.
	fmt.Printf("GC Runs: %d\n", m.NumGC)

	// TotalAlloc is cumulative bytes allocated.
	// Compare this with Alloc to estimate allocation churn.
	fmt.Printf("TotalAlloc: %d MB\n", m.TotalAlloc/1024/1024)
}

Watch NumGC over time. If it increments too fast, your GC is running too often. Watch Alloc. If it stays high even after GC runs, your live data is large or GOGC is too high. Compare TotalAlloc with Alloc. A huge gap means you are allocating and discarding a lot of temporary data, which puts pressure on the collector.

Pitfalls and runtime behavior

Setting GOGC incorrectly can break your service. The runtime does not prevent you from making bad choices.

If you set GOGC to a negative value, the runtime disables the collector. You won't get a compiler error. debug.SetGCPercent(-1) is valid. Your process will allocate memory until the operating system kills it with an out-of-memory error. This is useful for short-lived benchmarks where you want to eliminate GC overhead, but dangerous in production.

Setting GOGC too low causes GC thrashing. The collector runs so often that it consumes most of the CPU. Your application latency spikes because the GC pauses the world briefly during mark phases. The heap never grows large, but the service becomes unresponsive.

Setting GOGC too high causes memory bloat. The heap grows until it hits container limits or system memory. You might see the process killed by the OOM killer. The CPU usage stays low, but the service crashes.

The runtime has a built-in trace for the GC pacer. Enable it to see the internal decision-making.

# Run the binary with GC tracing enabled.
# gcpacertrace prints pacing decisions every 100ms.
# This helps you see if the GC is struggling to keep up.
GODEBUG=gcpacertrace=1 ./my-service

The GODEBUG environment variable is the standard way to enable internal traces. It is not a flag. It works across the entire Go ecosystem. gcpacertrace shows the target heap size, the current heap size, and whether the GC is running. Use this to debug tuning issues.

Convention aside: GODEBUG keys are often version-specific. Check the runtime documentation for available keys. gcpacertrace has been stable for many releases, but new keys appear and old ones may change.

Decision matrix

Use the default GOGC=100 when your workload is steady and memory usage is within limits. The default is tuned for general-purpose services and works well for most applications.

Use a higher GOGC value when memory is cheap and CPU is the bottleneck, or when you have large bursts of allocation that would trigger too many GC cycles. This reduces GC frequency at the cost of higher peak memory.

Use a lower GOGC value when memory is constrained and CPU headroom is available, such as in a tight container limit. This keeps memory usage low but increases GC overhead.

Use debug.SetGCPercent at runtime when you need to adapt to changing load patterns, like ramping up GC aggressiveness during a traffic spike or relaxing it during a batch processing phase.

Use GOGC=off only for short-lived benchmarks or batch jobs where you control the lifecycle and want to eliminate GC overhead entirely. Never use this in long-running services.

GOGC is a lever, not a magic wand. Pull it and watch the metrics. The default works for 90% of services. Don't tune what you can't measure.

Where to go next