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: GOGC says “collect when the heap grows by N%”. GOMEMLIMIT says “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:

go
// 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:

go
// 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:

go
// 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:

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:

go
// 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:

go
// 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:

go
// 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:bytes

That 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:

QuantityMeaning
heapInUseMemory in spans currently handed out to the program
heapFreeMemory in free spans, not yet returned to the OS
heapReleasedMemory the runtime has given back to the OS
mappedReadyTotal address space the runtime has committed
totalAlloc - totalFreeLive 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

  1. Set GOMEMLIMIT to roughly 80-90% of L. 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 exactly L means the process gets OOM-killed by the kernel before the runtime has a chance to react.

  2. Leave GOGC alone at first. The memory limit only takes effect when it produces a smaller goal, so with a generous limit you get the normal GOGC=100 behaviour with a safety net underneath.

  3. Watch /gc/heap/goal:bytes against 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.

  4. If you need the limit to bind less often, lower GOGC as well. The two are not in conflict; the smaller goal wins, so lowering GOGC gives you back CPU exactly when the limit is not the binding constraint.

A concrete example:

sh
# Container limit: 512 MiB
GOMEMLIMIT=460MiB   # ~90% of 512 MiB
GOGC=100             # default; the limit only takes over if needed
sh
# Container limit: 2 GiB, and the heap is genuinely large
GOMEMLIMIT=1800MiB
GOGC=200             # be lazy about collecting; the limit is the real constraint

Trade-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, readGOMEMLIMIT
  • src/runtime/debug/garbage.go — SetMemoryLimit and its documentation
  • src/runtime/extern.go — the GOGC and GOMEMLIMIT environment variables
  • src/runtime/mgcscavenge.go — the scavenger’s two goals
  • src/runtime/mheap.go — inline scavenging to stay under the limit
  • src/runtime/metrics/description.go — the heap goal and live heap metrics
  • src/runtime/mgclimit.go — the separate GC CPU limiter

GoInternalsMemoryContainersGarbage collector