# Go Perf Gc Tuning

> Guides Go GC tuning — GOGC (ratio, default 100) vs GOMEMLIMIT (1.19 soft memory limit), the GOGC=off+limit pattern and thrashing risk when too tight, reading GODEBUG=gctrace, SetGCPercent/SetMemoryLimit, ballast-is-obsolete, Green Tea GC (1.25 experiment, 1.26 default), and the primary lever — lower the allocation rate first. Fires on "tune the GC", "set GOMEMLIMIT", "reduce GC overhead", "GOGC", "too much time in GC". Routes which-allocs to go-perf-pprof-profiling, tail latency to go-perf-tail-latency.

- Skill: `ctoth/go-perf-gc-tuning` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-perf-gc-tuning`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-perf-gc-tuning/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-perf-gc-tuning

---


# Go GC Tuning

Whether GC is even your bottleneck is a **`go-perf-methodology`** (USE) question; *which* objects allocate
is a **`go-perf-pprof-profiling`** heap-profile question. This skill owns the layer after: the knobs —
`GOGC`, `GOMEMLIMIT`, the pacer — when to turn them, and why the first move almost never is one. All Go
below is `gofmt`/`go vet`/`go test`-clean on Go 1.25 / 1.26.

---

## 1. The #1 lever is the allocation rate, not a knob

Before any knob, lower how much you allocate. The GC's cost is paced by allocation: "GC frequency =
(Allocation rate) / (New heap memory)", and "the *total CPU cost* of the garbage collector depends on
the total number of GC cycles" ([GC Guide](https://go.dev/doc/gc-guide)). Halve the bytes allocated per
second and you halve GC frequency — a CPU *and* tail-latency win no knob delivers, because "reducing GC
frequency may also lead to latency improvements," applying "not only to … modifying tuning parameters …
but also … the optimizations" that cut allocation ([GC Guide](https://go.dev/doc/gc-guide)). A knob
trades memory for CPU; fewer allocations is free on both axes. Find them with a heap profile
(`-alloc_space`) → **`go-perf-pprof-profiling`**; cut them with **`go-perf-escape-analysis`**,
**`go-perf-sync-pool`**, **`go-perf-slices`** — and tune only once the rate is as low as it gets.

---

## 2. GOGC — the heap-growth ratio

`GOGC` (default `100`) sets how far the heap may grow between collections:

```text
Target heap memory = Live heap + (Live heap + GC roots) * GOGC / 100
```

so at the default the GC fires when the heap roughly *doubles* the live set. It "determines the
trade-off between GC CPU and memory," and "**doubling GOGC will double heap memory overheads and roughly
halve GC CPU cost**, and vice versa" ([GC Guide](https://go.dev/doc/gc-guide)). `GOGC=200` → fewer,
larger GCs (more RAM, less CPU, more throughput); `GOGC=50` → more frequent GCs. `GOGC=off` (or
`SetGCPercent(-1)`) disables the collector — "equivalent to setting GOGC to a value of infinity, as the
amount of new memory before a GC is triggered is unbounded" — but only "provided the [memory limit] does
not apply" ([GC Guide](https://go.dev/doc/gc-guide)).

---

## 3. GOMEMLIMIT — the soft memory limit (Go 1.19)

`GOMEMLIMIT` caps total runtime-managed memory — it "includes the Go heap and all other memory managed
by the runtime, and excludes external memory sources such as mappings of the binary itself, memory
managed in other languages, and memory held by the operating system" — a byte value with a
`B`/`KiB`/`MiB`/`GiB`/`TiB` suffix, default `math.MaxInt64`/off ([runtime env vars](https://pkg.go.dev/runtime#hdr-Environment_Variables)).

It is **soft** by design: "The Go runtime makes no guarantees that it will maintain this memory limit
under all circumstances; it only promises some reasonable amount of effort"
([GC Guide](https://go.dev/doc/gc-guide)). As the heap nears the limit the GC runs more often — the
runtime spends more CPU rather than exceed it. That CPU is bounded: it's capped at "roughly 50%, with a
`2 * GOMAXPROCS` CPU-second window," and past that the runtime lets "memory use surpass the limit to
avoid spending too much time in the GC" ([GC Guide](https://go.dev/doc/gc-guide)) — the escape hatch
that stops a soft limit becoming a stall (§5).

---

## 4. The GOGC=off + GOMEMLIMIT pattern

The two knobs compose. `GOGC=off` **plus** a memory limit is the canonical container-sizing config:
"even when GOGC is set to off, the memory limit is still respected … this … represents a *maximization
of resource economy* because it sets the minimum GC frequency required to maintain some memory limit,"
letting "the heap size rise to meet the memory limit" ([GC Guide](https://go.dev/doc/gc-guide)). Use it
for a service with a hard container limit and bursty live-heap: no wasteful early GCs, but a hard-ish
cap. Leave **5–10% headroom** — "leave an additional 5–10% of headroom to account for memory sources the
Go runtime is unaware of" ([GC Guide](https://go.dev/doc/gc-guide)). Prefer the `GOMEMLIMIT` / `GOGC`
**env vars** so ops can retune without a rebuild; use the API only when the limit is computed at startup:

```go
func init() {
	debug.SetGCPercent(-1)          // GOGC=off
	debug.SetMemoryLimit(900 << 20) // 900 MiB soft cap under a 1 GiB container limit
}
```

---

## 5. The thrashing / death-spiral risk

A limit you can't honor is dangerous. When the live heap alone nears `GOMEMLIMIT`, the GC runs
"constantly … to maintain an impossible memory limit" and total run time "grow[s] in an unbounded
manner" — **thrashing**, which "effectively stalls the program." The soft-limit relaxation (§3)
mitigates but doesn't cure it: "a large enough transient heap spike can cause a program to stall
indefinitely," and "an indefinite stall is worse than an out-of-memory condition, which tends to result
in a much faster failure" ([GC Guide](https://go.dev/doc/gc-guide)). The guide's blunt rule: **"Don't**
set a memory limit to avoid out-of-memory conditions when a program is already close to its environment's
memory limits" ([GC Guide](https://go.dev/doc/gc-guide)); the API agrees that "a limit … lower than the
amount of memory used by the Go runtime may cause the garbage collector to run nearly continuously"
([SetMemoryLimit](https://pkg.go.dev/runtime/debug#SetMemoryLimit)). **Do not configure a memory limit
you can't honor.** A small `GOGC` floor (not `off`) keeps the GC pacing below the limit so the cap is a
backstop, not a treadmill.

---

## 6. The pacer, mark-assist, and reading gctrace

The GC is concurrent but not free of the mutator: when allocation outruns the background mark workers,
the pacer makes the *allocating goroutine itself* do GC work — **mark assist** — so it can't
out-allocate the collector. Rising assist time is the tell that allocation is taxing latency; the fix is
§1 (allocate less) or a *higher* `GOGC`, never a lower one → **`go-perf-tail-latency`**. You read all
this from `GODEBUG=gctrace=1`, which "emit[s] a single line to standard error at each collection"
([runtime env vars](https://pkg.go.dev/runtime#hdr-Environment_Variables)):

```text
gc 1 @0.012s 2%: 0.018+1.2+0.004 ms clock, 0.14+0.31/1.1/0+0.032 ms cpu, 4->4->2 MB, 5 MB goal, 0 MB stacks, 0 MB globals, 8 P
```

- `gc 1` GC number; `@0.012s` time since start; `2%` — **percentage of total time spent in GC since
  program start** (the number to watch — high and rising means GC-bound).
- `... ms clock` — phases: STW sweep-termination + concurrent mark/scan + STW mark-termination. The two
  STW figures are your pauses — typically **sub-millisecond**.
- `... ms cpu` — the mark/scan triple is **assist / background / idle** (assist = the mutator tax above).
- `4->4->2 MB` — heap **at GC start → end → live**; `5 MB goal` — the §2 target. Trailing `(forced)` =
  a `runtime.GC()` call.

For monitoring prefer **`runtime/metrics`** (`/gc/heap/goal:bytes`, `/sched/pauses/total/gc:seconds`,
`/gc/gogc:percent`) over scraping this text or the stop-the-world `runtime.ReadMemStats` —
catalog → **`go-perf-godebug-and-metrics`**.

---

## 7. SetGCPercent / SetMemoryLimit — the programmatic knobs

`runtime/debug` mirrors the env vars at runtime. `SetGCPercent(percent int) int` "sets the garbage
collection target percentage … returns the previous setting"; "a negative percentage effectively
disables garbage collection, unless the memory limit is reached"
([SetGCPercent](https://pkg.go.dev/runtime/debug#SetGCPercent)). `SetMemoryLimit(limit int64) int64`
"provides the runtime with a soft memory limit" in **bytes**, "respected even if GOGC=off"; a negative
input reads the current limit without changing it, and `math.MaxInt64` disables it
([SetMemoryLimit](https://pkg.go.dev/runtime/debug#SetMemoryLimit)).

---

## 8. Ballast is obsolete

Before `GOMEMLIMIT` (Go 1.19), the trick for a service that GC'd too often at a tiny live heap was a
**ballast** — a big pointer-free allocation that inflates the heap so `GOGC`'s doubling rule fires later:

```go
ballast := make([]byte, 10<<30) // 10 GiB — DON'T (pre-1.19 hack)
_ = ballast
```

It worked because the slice has no pointers (it "marks in O(1)") and stays virtual until touched, so it
cost little real RAM ([Twitch, 2019 — superseded](https://blog.twitch.tv/en/2019/04/10/go-memory-ballast-how-i-learnt-to-stop-worrying-and-love-the-heap/)).
But it's a fragile proxy for "don't GC until ~N bytes"; **`GOMEMLIMIT` states that intent directly**,
even with `GOGC=off` (§3–4). In 2026, set a memory limit, never a ballast.

---

## 9. Green Tea GC (1.25 experiment → 1.26 default)

Green Tea is a redesigned mark/scan working on memory **pages, not individual objects**, with "somewhere
between a 10–40% reduction in garbage collection overhead in real-world programs that heavily use the
garbage collector," plus ~10% more on newer amd64 via vector scanning
([Go 1.26](https://go.dev/doc/go1.26)). The version status, precisely:

- **Go 1.25:** opt-in experiment — "may be enabled by setting `GOEXPERIMENT=greenteagc` at build time"
  ([Go 1.25](https://go.dev/doc/go1.25)).
- **Go 1.26:** **on by default** — "previously available as an experiment in Go 1.25, is now enabled by
  default after incorporating feedback." Opt out with `GOEXPERIMENT=nogreenteagc`; that opt-out "is
  expected to be removed in Go 1.27" ([Go 1.26](https://go.dev/doc/go1.26)).

No new tunables — it shifts the GC CPU baseline, so **re-baseline benchmarks after a 1.25→1.26 upgrade**.

---

## 10. Don't

- **Don't tune a knob before lowering the allocation rate** — fewer allocations cuts GC CPU *and*
  latency for free; a knob only trades memory for CPU (§1).
- **Don't confuse `GOGC` (relative growth ratio) with `GOMEMLIMIT` (absolute byte cap)** — `GOGC=100`
  means "double the live heap"; `GOMEMLIMIT` is a fixed ceiling in bytes (§2–3).
- **Don't set a `GOMEMLIMIT` you can't honor** — a too-tight limit thrashes, and "an indefinite stall is
  worse than an out-of-memory condition" (§5, [GC Guide](https://go.dev/doc/gc-guide)).
- **Don't run `GOGC=off` with no memory limit on an unbounded-heap service** — that's `GOGC=∞`, an OOM
  waiting to happen; and **don't add a memory ballast in 2026** — superseded by `GOMEMLIMIT` (§2, §8).
- **Don't assume Go STW pauses are long** — sub-millisecond; the tail lever is allocation rate, and to
  monitor it use `runtime/metrics`, not `gctrace`-scraping or `runtime.ReadMemStats` (§6).
- **Don't read a GC delta across a 1.25→1.26 upgrade without re-baselining** — Green Tea moved the
  baseline (§9).

---

## 11. Routing to Related Skills

**This plugin (`go-perf-*` depth):**

- `go-perf-pprof-profiling` — heap profile (`-alloc_space`) to find *which* allocations to cut.
- `go-perf-allocator-internals` — what an allocation costs; scan vs no-scan spans; size classes.
- `go-perf-sync-pool`, `go-perf-slices`, `go-perf-escape-analysis` — the levers that lower allocation rate.
- `go-perf-tail-latency` — p99/p999, coordinated omission, allocation-rate→GC-frequency framing.
- `go-perf-godebug-and-metrics` — the `runtime/metrics` GC catalog and GODEBUG knobs.
- `go-perf-methodology` — USE method: confirm GC is the bottleneck before tuning.

**Parent / sibling marketplace (`go-*` correctness):** `go-performance` — the measure-first overview
this deepens; `go-version-feature-map` — GOMEMLIMIT floor (1.19), Green Tea default (1.26).

---

## 12. Reference Files

GC-tuning anti-patterns in LLM-generated Go, each wrong/right with citations:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

