Every Go program ships with a garbage collector, and most run happily with the defaults. That changes the moment you deploy into a container with a hard memory limit. Suddenly you’re seeing OOM kills at 3 a.m., or the opposite: a service burning a third of its CPU in collection while using a fraction of its allocated memory. Both failures have the same root cause — the collector knows nothing about the environment you put it in. The two knobs that fix it are GOGC and GOMEMLIMIT.
How the Go collector behaves by default
Go’s collector is a concurrent, tracing, mark-sweep garbage collector. It walks the object graph from goroutine stacks and globals to find live memory, then makes unreachable memory available for allocation again. Most marking happens concurrently with your program, keeping stop-the-world pauses short. It’s also non-moving: objects stay where they were allocated.
Default behavior is governed by GOGC=100. After each cycle, the runtime picks a target total heap size and lets the program allocate until that target is reached, at which point the next cycle starts. The target is computed from the live heap:
Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100
With GOGC=100, the heap is allowed to grow to twice the live heap before a new cycle begins. A program holding 8 MiB of live objects gets a target around 18 MiB once you include the goroutine stacks and global pointers that count as GC roots. Two properties fall out of this formula, and they explain almost everything you’ll observe in production:
Doubling GOGC roughly doubles memory overhead and roughly halves the CPU spent collecting. Halving it does the reverse.
The target scales with the live heap, so a program with a tiny live set collects frequently even when it allocates heavily, while a large live set buys proportionally more headroom.
This makes GOGC a dial for a fundamental time/space trade-off, and on a machine with memory to spare it’s a perfectly good dial. The trouble starts when memory is bounded by something the runtime can’t see.
Why GOGC breaks in containers
Consider a service with a steady live heap of 20 MiB but occasional spikes to 80 MiB, deployed into a container limited to 128 MiB. The kernel enforces the cgroup limit by killing the process — the GC never gets a say, and the limit is invisible to it. With default GOGC=100, a spike that doubles the live heap also doubles the headroom the collector grants itself. If the spike lands faster than the collector can reclaim memory, the cgroup limit is breached first and the OOM killer terminates the process. No panic, no stack trace — the container just restarts.
The traditional fix was to lower GOGC so even peak spikes fit. That works, but it costs CPU: more frequent cycles mean more marking. You end up tuning for the worst-case spike while paying the tax on every ordinary cycle.
GOMEMLIMIT: a soft limit the runtime knows about
Go 1.19 introduced a second, independent control: a soft memory limit for the total memory managed by the Go runtime. Set it with the GOMEMLIMIT environment variable or programmatically via debug.SetMemoryLimit. The limit counts heap plus runtime-managed memory — roughly Sys - HeapReleased in terms of runtime.MemStats — and the collector takes it into account when scheduling cycles. As the total approaches the limit, the GC runs more often, regardless of what GOGC would otherwise allow.
The crucial word is soft. The runtime makes a reasonable effort to stay under the limit but guarantees nothing, and that’s deliberate. Imagine the live heap itself grows past the limit — the condition is genuinely unsatisfiable. If the GC were obligated to enforce it anyway, it would spin in back-to-back cycles, making no useful progress while consuming CPU. This state, where the program stalls in endless collection instead of failing, is called thrashing, and an indefinite stall is far worse than a fast crash: requests time out, upstream callers pile up, and the failure spreads. A dead container restarts in seconds; a zombie one poisons everything around it.
Go’s defense is a GC CPU cap: the collector limits itself to roughly 50% of CPU time. If honoring the limit would take more than that, the GC lets the process exceed it instead. In the worst misconfiguration your program slows down by at most about 2x rather than freezing — and eventually the OOM killer resolves the situation anyway.
The modern pattern for containers
Once the memory limit exists, a new strategy opens up. GOGC‘s job was always to balance CPU against memory while flying blind about how much memory was actually available. GOMEMLIMIT removes the blindness. So hand the memory question to the limit entirely and let GOGC off the leash:
GOGC=off
GOMEMLIMIT=120MiB
With GOGC=off, collections are triggered by nothing but the memory limit. The heap grows as large as the limit permits, cycles happen as rarely as possible, and GC CPU overhead drops to its minimum. This is the maximum-resource-economy configuration, and it’s the recommended pattern for containerized services where the Go process is the main resident of the container.
Two details make it safe in practice. First, leave headroom between GOMEMLIMIT and the container’s cgroup limit — the runtime doesn’t account for memory it can’t see, such as cgo allocations or OS-level overhead. A good rule of thumb is 5-10%. Second, the pattern assumes the limit reflects real, reserved memory. If the Go process shares a machine with unrelated co-tenants — or the sum of container limits on a node exceeds physical memory — GOGC=off will greedily consume up to the limit and starve the neighbors. In shared-memory situations, keep GOMEMLIMIT as a safety net but leave GOGC at a modest value (say 50-100) for the average case.
It’s equally worth knowing when not to set a limit at all. CLI tools and desktop apps shouldn’t bake in a GOMEMLIMIT — the right value depends on the machine and input, and a user can always set it externally. And if a service already brushes against its container limit, adding GOMEMLIMIT just converts an OOM into a severe slowdown; raise the container limit first.
A practical Dockerfile
Putting it together for a containerized web service with a 512 MiB limit:
FROM golang:1.24 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/srv ./cmd/srv
FROM gcr.io/distroless/static-debian12
COPY --from=build /bin/srv /bin/srv
# Container has a 512Mi memory limit; leave ~6% headroom.
ENV GOMEMLIMIT=480MiB GOGC=off
ENTRYPOINT ["/bin/srv"]
If you’re not ready to disable GOGC outright, the conservative variant is GOMEMLIMIT=480MiB on its own — GOGC keeps the heap small in the good case, and the limit catches any spike that would otherwise OOM the container. Programs that need to adapt at runtime get both knobs through the runtime/debug package:
package main
import (
"fmt"
"runtime"
"runtime/debug"
)
func main() {
// Soft memory limit: 480 MiB, matching the container budget.
debug.SetMemoryLimit(480 << 20)
// Let the heap grow to the limit instead of a GOGC-derived target.
debug.SetGCPercent(-1)
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("live heap: %d MiB\n", m.HeapAlloc>>20)
}
SetGCPercent(-1) disables the GOGC-driven target exactly like GOGC=off, while SetMemoryLimit mirrors the environment variable. Changing either at runtime is legitimate — a common pattern is adjusting the limit when an embedded C library temporarily needs more memory.
Observing GC behavior
None of this tuning matters without measurement. The quickest instrument is the built-in GC trace:
GODEBUG=gctrace=1 ./srv
Each collection then prints one line to stderr:
gc 42 @120.453s 0%: 0.089+2.1+0.031 ms clock, 0.71+0.42/8.4/0+0.12 ms cpu, 15->16->8 MB, 18 MB goal, 8 P
Reading it: 42 cycles in 120 seconds, GC consumed 0% of CPU, and the heap went from 15 MB before the cycle to 8 MB live afterward, against a goal of 18 MB. Watch two things over time. If the goal value stops rising when GOGC=off is set, the memory limit is the binding constraint — the GC is pacing against GOMEMLIMIT, exactly as designed. If lines appear back-to-back, the limit sits below something the workload genuinely needs.
For production systems, programmatic metrics beat stderr parsing. The runtime/metrics package exposes everything the GC knows, including the values the memory limit is computed from:
package main
import (
"fmt"
"runtime/metrics"
)
func main() {
names := []string{
"/gc/heap/live:bytes",
"/gc/cycles/total:gc-cycles",
"/gc/gomemlimit:bytes",
"/memory/classes/total:bytes",
"/memory/classes/heap/released:bytes",
}
samples := make([]metrics.Sample, len(names))
for i, n := range names {
samples[i].Name = n
}
metrics.Read(samples)
for i, s := range samples {
fmt.Printf("%s = %v\n", names[i], s.Value)
}
}
Exporting these alongside your Prometheus metrics lets you chart live heap against the memory limit. A large permanent gap means you can raise limit utilization or revert to GOGC-based pacing; a gap that periodically vanishes means the live set peaks near the limit, and headroom needs revisiting.
A decision checklist
Container with a memory limit and a single Go process? Set GOMEMLIMIT to the cgroup limit minus 5-10%, and GOGC=off if the container’s memory isn’t shared with other processes.
Getting OOM kills with default GOGC=100? Add GOMEMLIMIT first — it addresses the spike problem directly.
GC CPU too high on a memory-rich host? Raise GOGC (or go off with a limit) rather than buying cores.
Process alive but crawling, with constant gctrace lines? The limit is set below the workload’s real needs. Raise it or accept the OOM.
Wrapping up
GOGC and GOMEMLIMIT answer different questions. GOGC says “spend this much memory to save that much CPU,” expressed as a ratio of the live heap. GOMEMLIMIT says “never knowingly exceed this footprint,” in absolute bytes. Default GOGC=100 remains reasonable where memory is plentiful, but GOMEMLIMIT just under the container limit combined with GOGC=off (or a moderate value in shared environments) is the modern baseline for containerized Go services. Turn on GODEBUG=gctrace=1 in staging, export the runtime/metrics GC series in production, and let the measured heap-versus-limit curve tell you whether the configuration is doing its job.