GOMEMLIMIT and the Go Runtime's Memory Limit: Trading GC CPU for RAM
GOMEMLIMIT gives the Go runtime a soft ceiling on total runtime-managed memory, and when that ceiling approaches, the runtime shrinks the heap goal, collects more often and scavenges harder instead of letting the process grow until the kernel kills it.
TL;DR:
GOGCsays “collect when the heap grows by N%”.GOMEMLIMITsays “never let the runtime’s total memory exceed this”. When the two disagree, the runtime takes the smaller goal: the heap goal becomes whatever keeps the process under the limit, the collector runs more often, and the scavenger starts releasing pages back to the OS much more aggressively. The cost is GC CPU; the benefit is not being OOM-killed.
Every Go service that runs in a container eventually learns the same lesson.
The Kubernetes limit says 512 MiB. The Go runtime does not know that. It
knows GOGC=100, so it lets the heap grow to twice the live set, and then
the kernel sends SIGKILL.
GOMEMLIMIT exists to fix that, and it is one of the few knobs in the Go
runtime that is genuinely about your deployment rather than about your
data.
Two knobs, one goal
The runtime maintains a single number that drives everything: the heap goal, meaning “the target size of the live heap at the end of the next GC cycle”. Two things can set it.
GOGC sets the goal as a percentage of the live heap. The classic formula
is still right there in the pacer:
// src/runtime/mgcpacer.go:1290
// Compute the next GC goal, which is when the allocated heap
// has grown by GOGC/100 over where it started the last cycle,
// plus additional runway for non-heap sources of GC work.
gcPercentHeapGoal := ^uint64(0)
if gcPercent := c.gcPercent.Load(); gcPercent >= 0 {
gcPercentHeapGoal = c.heapMarked + (c.heapMarked+c.lastStackScan.Load()+c.globalsScan.Load())*uint64(gcPercent)/100
}
// Apply the minimum heap size here. It's defined in terms of gcPercent
// and is only updated by functions that call commit.
if gcPercentHeapGoal < c.heapMinimum {
gcPercentHeapGoal = c.heapMinimum
}
c.gcPercentHeapGoal.Store(gcPercentHeapGoal)GOMEMLIMIT sets a completely different kind of target: a ceiling on all
the memory the runtime is holding, not just the live heap. The two are
computed separately and then reconciled.
Reading the environment
The limit starts out as math.MaxInt64 unless GOMEMLIMIT says otherwise:
// src/runtime/mgcpacer.go:1414
func readGOMEMLIMIT() int64 {
p := gogetenv("GOMEMLIMIT")
if p == "" || p == "off" {
return math.MaxInt64
}
n, ok := parseByteCount(p)
if !ok {
print("GOMEMLIMIT=", p, "\n")
throw("malformed GOMEMLIMIT; see `go doc runtime/debug.SetMemoryLimit`")
}
return n
}parseByteCount accepts the IEC suffixes, so GOMEMLIMIT=512MiB,
GOMEMLIMIT=1GiB and GOMEMLIMIT=off all work. off means no limit, and
so does the empty string.
GOGC has the same shape of parser:
// src/runtime/mgcpacer.go:1368
func readGOGC() int32 {
p := gogetenv("GOGC")
if p == "off" {
return -1
}
if n, err := strconv.ParseInt(p, 10, 32); err == nil {
return int32(n)
}
return 100
}Both are read once at startup, in mgc.go:
// src/runtime/mgc.go:188
// Use the environment variable GOMEMLIMIT for the initial memoryLimit value.
gcController.init(readGOGC(), readGOMEMLIMIT())Changing it at runtime
The environment variable is only the initial value. You can change the limit at any time:
// src/runtime/debug/garbage.go:231
// SetMemoryLimit returns the previously set memory limit.
// A negative input does not adjust the limit, and allows for
// retrieval of the currently set memory limit.
func SetMemoryLimit(limit int64) int64 {
return setMemoryLimit(limit)
}and the runtime side takes the heap lock and recommits the pacer:
// src/runtime/mgcpacer.go:1383
func (c *gcControllerState) setMemoryLimit(in int64) int64 {
if !c.test {
assertWorldStoppedOrLockHeld(&mheap_.lock)
}
out := c.memoryLimit.Load()
if in >= 0 {
c.memoryLimit.Store(in)
}
return out
}Passing a negative value is the documented way to read the current limit without changing it.
The documentation is worth reading in full, because it is precise about what the limit covers:
// src/runtime/debug/garbage.go:181
// SetMemoryLimit provides the runtime with a soft memory limit.
//
// The runtime undertakes several processes to try to respect this
// memory limit, including adjustments to the frequency of garbage
// collections and returning memory to the underlying system more
// aggressively. This limit will be respected even if GOGC=off (or,
// if [SetGCPercent](-1) is executed).
//
// The input limit is provided as bytes, and includes all memory
// mapped, managed, and not released by the Go runtime. Notably, it
// does not account for space used by the Go binary and memory
// external to Go, such as memory managed by the underlying system
// on behalf of the process, or memory managed by non-Go code inside
// the same process. Examples of excluded memory sources include: OS
// kernel memory held on behalf of the process, memory allocated by
// C code, and memory mapped by syscall.Mmap (because it is not
// managed by the Go runtime).
//
// More specifically, the following expression accurately reflects
// the value the runtime attempts to maintain as the limit:
//
// runtime.MemStats.Sys - runtime.MemStats.HeapReleased
//
// or in terms of the [runtime/metrics] package:
//
// /memory/classes/total:bytes - /memory/classes/heap/released:bytesThat last bit is the single most useful sentence in the Go documentation for
operating Go in a container. The limit applies to Sys - HeapReleased. It
does not apply to your binary’s text segment, to a cgo library’s
allocations, to a syscall.Mmap region, or to the kernel’s own memory on
your behalf.
What the limit actually counts
That distinction matters. The runtime tracks several quantities:
| Quantity | Meaning |
|---|---|
heapInUse | Memory in spans currently handed out to the program |
heapFree | Memory in free spans, not yet returned to the OS |
heapReleased | Memory the runtime has given back to the OS |
mappedReady | Total address space the runtime has committed |
totalAlloc - totalFree | Live heap object bytes |
mappedReady is the number the limit is compared against. It is the total
the runtime has committed to the process, whether or not the program is
currently using it. That is deliberately a bigger number than HeapAlloc,
because the number that kills your container is RSS, not live heap.
Turning the limit into a goal
Here is where the two constraints meet:
// src/runtime/mgcpacer.go:996
// heapGoalInternal is the implementation of heapGoal which returns additional
// information that is necessary for computing the trigger.
//
// The returned minTrigger is always <= goal.
func (c *gcControllerState) heapGoalInternal() (goal, minTrigger uint64) {
// Start with the goal calculated for gcPercent.
goal = c.gcPercentHeapGoal.Load()
// Check if the memory-limit-based goal is smaller, and if so, pick that.
if newGoal := c.memoryLimitHeapGoal(); newGoal < goal {
goal = newGoal
} else {That is the whole reconciliation: the smaller goal wins. If the
memory-limit-derived goal is below the GOGC-derived goal, the runtime uses
the smaller one and the GOGC setting stops mattering. If the limit is not
binding, the GOGC goal is used and everything behaves exactly as before.
Note the else branch, which the source comments explain carefully: in the
memory-limit regime the runtime skips the sweep-distance and minimum-runway
adjustments, because those push the goal up, and the last thing you want
when you are approaching a limit is a higher goal.
The arithmetic
Computing the limit-based goal is the gnarliest function in the pacer, and the runtime explains it at length:
// src/runtime/mgcpacer.go:1045
// memoryLimitHeapGoal returns a heap goal derived from memoryLimit.
func (c *gcControllerState) memoryLimitHeapGoal() uint64 {
// Start by pulling out some values we'll need. Be careful about overflow.
var heapFree, heapAlloc, mappedReady uint64
for {
heapFree = c.heapFree.load() // Free and unscavenged memory.
heapAlloc = c.totalAlloc.Load() - c.totalFree.Load() // Heap object bytes in use.
mappedReady = c.mappedReady.Load() // Total unreleased mapped memory.
if heapFree+heapAlloc <= mappedReady {
break
}The source then spells out the formula in a comment, which is the clearest statement of the design anywhere in the runtime:
// src/runtime/mgcpacer.go:1071
// In practice this computation looks like the following:
//
// goal := memoryLimit - ((mappedReady - heapFree - heapAlloc) + max(mappedReady - memoryLimit, 0))
// ^1 ^2
// goal -= goal / 100 * memoryLimitHeapGoalHeadroomPercent
// ^3
//
// Let's break this down.
//
// The first term (marker 1) is everything that contributes to the memory limit and isn't
// or couldn't become heap objects. It represents, broadly speaking, non-heap overheads.
// One oddity you may have noticed is that we also subtract out heapFree, i.e. unscavenged
// memory that may contain heap objects in the future.Term 1 is non-heap overhead: memory the runtime has committed that will never become a Go object. Term 2 is how far over the limit you already are. Term 3 is headroom.
And then the headroom, which exists for a specific and important reason:
// src/runtime/mgcpacer.go:1139
// Apply some headroom to the goal to account for pacing inaccuracies and to reduce
// the impact of scavenging at allocation time in response to a high allocation rate
// when GOGC=off. See issue #57069. Also, be careful about small limits.
headroom := goal / 100 * memoryLimitHeapGoalHeadroomPercent
if headroom < memoryLimitMinHeapGoalHeadroom {
// Set a fixed minimum to deal with the particularly large effect pacing inaccuracies
// have for smaller heaps.
headroom = memoryLimitMinHeapGoalHeadroom
}
if goal < headroom || goal-headroom < headroom {
goal = headroom
} else {
goal = goal - headroom
}The headroom percentage is 3%, with a 1 MiB floor, because pacing inaccuracies are proportionally much worse on small heaps.
What happens when the limit binds
Three things change at once.
The collector runs more often. The heap goal drops, so the trigger drops, so GC cycles come faster. That is the GC CPU you are paying for.
The scavenger changes target. Instead of aiming at a percentage of the heap goal, it aims at 95% of the limit:
// src/runtime/mgcscavenge.go:167
func gcPaceScavenger(memoryLimit int64, heapGoal, lastHeapGoal uint64) {
assertWorldStoppedOrLockHeld(&mheap_.lock)
// As described at the top of this file, there are two scavenge goals here: one
// for gcPercent and one for memoryLimit. Let's handle the latter first because
// it's simpler.
// We want to target retaining (100-reduceExtraPercent)% of the heap.
memoryLimitGoal := uint64(float64(memoryLimit) * (1 - reduceExtraPercent/100.0))
// mappedReady is comparable to memoryLimit, and represents how much total memory
// the Go runtime has committed now (estimated).
mappedReady := gcController.mappedReady.Load()
// If we're below the goal already indicate that we don't need the background
// scavenger for the memory limit. This may seems worrisome at first, but note
// that the allocator will assist the background scavenger in the face of a memory
// limit, so we'll be safe even if we stop the scavenger when we shouldn't have.
if mappedReady <= memoryLimitGoal {
scavenge.memoryLimitGoal.Store(^uint64(0))
} else {
scavenge.memoryLimitGoal.Store(memoryLimitGoal)
}Shooting at 95% rather than 100% is deliberate: it gives the scavenger something to do before the limit is hit, instead of it only reacting at the boundary.
The allocator starts scavenging inline. When an allocation is about to page memory back in, and doing so would push you over the limit, the allocator scavenges to make room before it commits:
// src/runtime/mheap.go:1318
// to do this before calling sysUsed because that may commit address space.
bytesToScavenge := uintptr(0)
forceScavenge := false
if limit := gcController.memoryLimit.Load(); !gcCPULimiter.limiting() {
// Assist with scavenging to maintain the memory limit by the amount
// that we expect to page in.
inuse := gcController.mappedReady.Load()
// Be careful about overflow, especially with uintptrs. Even on 32-bit platforms
// someone can set a really big memory limit that isn't math.MaxInt64.
if uint64(scav)+inuse > uint64(limit) {
bytesToScavenge = uintptr(uint64(scav) + inuse - uint64(limit))
forceScavenge = true
}
}This is the mechanism that makes the limit work rather than merely advise. The allocator refuses to page memory back in unless it can free some first.
Watching it
The runtime exposes the goal directly:
// src/runtime/metrics/description.go:289
{
Name: "/gc/heap/goal:bytes",
Description: "Heap size target for the end of the GC cycle.",
Kind: KindUint64,
},
{
Name: "/gc/heap/live:bytes",
Description: "Heap memory occupied by live objects that were marked by the previous GC.",
Kind: KindUint64,
},/gc/heap/goal:bytes is the number to watch. If it is sitting at or near
your GOGC-derived value, the memory limit is not binding and the limit is
doing nothing for you. If it is pinned much lower, the limit is binding and
you are paying for it in GC CPU.
The pair /memory/classes/total:bytes and
/memory/classes/heap/released:bytes tells you whether the runtime is
keeping its promise about total memory.
The subtraction between those two is exactly the quantity the limit is
measured against, so a quick dashboard check is: read total:bytes and
heap/released:bytes, subtract, and compare with your GOMEMLIMIT. If the
result is comfortably below the limit, the scavenger is doing its job. If it
is pinned at the limit, the runtime is living on the edge and the next
allocation burst is what kills you.
The practical recipe
The limit is a lever, not a switch. It only does something once the limit-derived goal drops below the GOGC-derived one, which means it engages late in the life of a process that never allocates much and immediately in the life of one that allocates a lot.
For a container with a memory limit of L:
Set
GOMEMLIMITto roughly 80-90% ofL. Not 100%: the runtime’s own accounting excludes memory it does not manage (your binary’s text, cgo allocations, kernel-held memory), and the headroom inside the runtime covers pacing error. Setting it to exactlyLmeans the process gets OOM-killed by the kernel before the runtime has a chance to react.Leave
GOGCalone at first. The memory limit only takes effect when it produces a smaller goal, so with a generous limit you get the normalGOGC=100behaviour with a safety net underneath.Watch
/gc/heap/goal:bytesagainst your live set. If the goal is tracking the live set, you have headroom. If it is pinned below it, the limit is binding and GC frequency is going up.If you need the limit to bind less often, lower
GOGCas well. The two are not in conflict; the smaller goal wins, so loweringGOGCgives you back CPU exactly when the limit is not the binding constraint.
A concrete example:
# Container limit: 512 MiB
GOMEMLIMIT=460MiB # ~90% of 512 MiB
GOGC=100 # default; the limit only takes over if needed# Container limit: 2 GiB, and the heap is genuinely large
GOMEMLIMIT=1800MiB
GOGC=200 # be lazy about collecting; the limit is the real constraintTrade-offs
What the limit buys you. The process stays inside its container instead of being OOM-killed at 3am. The GC gets a chance to shrink the heap on its own terms, with a stop-the-world collection and a proper sweep, instead of the kernel pulling the rug out.
What it costs. GC CPU. When the limit binds, the collector runs more often, and the scavenger runs harder. On a workload that is already CPU bound, that can be a straight trade of memory for throughput.
What it does not cover. Memory outside the Go runtime: cgo, mmap,
the binary itself, and anything the kernel holds on your behalf. A Go
process with 100 MiB of cgo allocations needs its GOMEMLIMIT set that much
lower than the container limit.
Why a soft limit and not a hard one. A hard limit would mean refusing allocations, which in Go means the program crashes with an out-of-memory error. A soft limit means the runtime tries hard to stay under, and if it cannot, the program gets slower rather than dying.
Caveats
It is soft. The runtime tries. Under a burst allocation rate it can overshoot, and the kernel can still kill you.
It is not a substitute for setting a container limit. GOMEMLIMIT and
the cgroup limit are two different mechanisms and you want both.
Setting it too low is a performance cliff. The goal calculation has a
path where, if non-heap memory alone exceeds the limit, the goal collapses
to heapMarked and the runtime collects continuously. The runtime’s own
documentation warns: “A zero limit or a limit that’s lower than the amount
of memory used by the Go runtime may cause the garbage collector to run
nearly continuously.”
GOMEMLIMIT and GOGC=off interact. The limit is respected even when
GOGC=off. That is a genuinely useful combination — you get “collect only
when forced to by memory pressure” — but it means your program can now
allocate unboundedly until it hits the limit, and then thrash.
SetMemoryLimit is not free. It takes the heap lock and recommits the
pacer. Call it at startup, not in a hot loop.
The value you set is not the value you get. The runtime’s goal is
derived, with a 3% headroom and a 1 MiB floor, and the limit is compared
against Sys - HeapReleased, which changes as the scavenger works. Expect
the observed number to be somewhat below the limit you set, not exactly at
it.
32-bit platforms overflow. The runtime has explicit overflow guards
around the limit arithmetic because a uintptr limit on a 32-bit system
cannot represent math.MaxInt64.
Wrapping up
The one thing to remember: the runtime computes two heap goals — one from
GOGC, one from the memory limit — and uses whichever is smaller. Setting
GOMEMLIMIT does not “turn on” a different allocator; it just means the
limit-derived goal eventually wins, and the collector and the scavenger both
get more aggressive as you approach the ceiling.
The part people get wrong: the limit is about the runtime’s total
committed memory, Sys - HeapReleased, not about HeapAlloc. Memory held
by cgo, by mmap, by the binary’s own text segment, or by the kernel is not
counted, so setting GOMEMLIMIT equal to your container limit leaves no
room for any of it.
What to go do about it: set GOMEMLIMIT to 80-90% of your container
limit, leave GOGC alone until you have measured, and then watch
/gc/heap/goal:bytes. If the goal tracks your live set, the limit is
insurance and costs nothing. If it is pinned well below, the limit is
binding and you are paying GC CPU for the memory you are saving — decide
whether that trade is worth it.
Further reading
src/runtime/mgcpacer.go— the heap goal, the limit arithmetic,readGOMEMLIMITsrc/runtime/debug/garbage.go—SetMemoryLimitand its documentationsrc/runtime/extern.go— theGOGCandGOMEMLIMITenvironment variablessrc/runtime/mgcscavenge.go— the scavenger’s two goalssrc/runtime/mheap.go— inline scavenging to stay under the limitsrc/runtime/metrics/description.go— the heap goal and live heap metricssrc/runtime/mgclimit.go— the separate GC CPU limiter