Search and navigate

Pages

Start HereRoadmapTutorialsCheatsheetInterview QuestionsResources

Published tutorials

Building a REST API with Go: The Complete TutorialError Handling in Go: Wrapping, Classification, and APIsFan-Out Fan-In in Go: Parallel Work with ChannelsFuzz Testing in Go: Finding Bugs AutomaticallyGeneric Data Structures in Go: Stack, Queue and SetGeneric Functions in Go: Writing Reusable CodeGin in Go: Build and Test a Small JSON APIGo Benchmarking: Measuring Performance with testing.BGo Channel Patterns: Advanced Recipes That WorkGo Channels: Communication Between GoroutinesGo Concurrency Patterns: A Practical CatalogueGo Constraints: comparable, Ordered and Custom SetsGo Context: Cancellation, Deadlines, and Request ValuesGo defer: Cleanup, Ordering, and Common TrapsGo HTTP Client: Timeouts, JSON, and Reliable RequestsGo HTTP Middleware: Build a Production ChainGo Interview Questions: 25 Answers That Show UnderstandingGo Mutex: Protecting Shared State from Race ConditionsGo net/http: Build an HTTP Server from ScratchGo select Statement: Multiplexing Channels ExplainedGo Tools You Need for a Reliable Development LoopGo Tutorial: From First Program to Backend FoundationsGo Type Parameters: Syntax and Constraints ExplainedGo Worker Pool Pattern: Bounded Concurrency at ScaleGo's Garbage Collector Explained: How Memory Is ManagedGo's Type System Deep Dive: Static Typing Done RightGolang Compilation and Execution ExplainedGolang Developer Salary 2026: US, UK and RemoteGolang File Structure for Modules and ApplicationsGolang Hello World and Your First Go ProgramGolang IDEs: Choose an Editor for Your WorkflowGolang Interfaces: What They Are and How to Use ThemGoroutine Leaks in Go: How to Detect and Prevent ThemGoroutines in Go: Concurrency Without LeaksHow to Download and Install Golang SafelyHow to Learn Golang with a Focused Study PlanIntegration Testing in Go: Testing Real BoundariesJSON in Go: Encoding, Decoding, and ValidationLearn Golang with a Practical Beginner CourseMocking in Go: Test Doubles Without Magicsync.WaitGroup in Go: Coordinating Multiple GoroutinesTable-Driven Tests in Go: The Idiomatic WayTesting in Go: Table Tests, HTTP, Fakes, and CoverageWhat Is Go and How Does a Go Program Work?Why Learn Golang and What Is Go Used For?Writing Unit Tests in Go: A Practical Guide
Open full search and filters →

Go's Garbage Collector Explained: How Memory Is Managed

How the Go garbage collector works: concurrent mark-and-sweep, escape analysis, GOGC and GOMEMLIMIT, plus cutting allocations with measured benchmarks.

Standard-library lessonGo requirement: Go 1.24+ (GOMEMLIMIT needs Go 1.19+)How tutorials are checked
Generics · Lesson 5Saved in this browser. No account required.
Reachable memory nodes retained while an unreachable node is swept away
The collector traces reachable objects and reclaims heap memory that can no longer be reached. Image: Golang Tutorial

Go’s Garbage Collector Explained: How Memory Is Managed

This tutorial explains how Go manages memory and when you should care. You will see how escape analysis decides stack versus heap, how the concurrent collector works, what GOGC and GOMEMLIMIT actually do, how to read GC behavior with real tools, and how to cut a function’s allocations with measured before and after numbers. Every command and benchmark here was run on Go 1.24.7, and the examples were reviewed against Go 1.26.5.

Works with any recent Go (the collector model has been stable since Go 1.5); GOMEMLIMIT needs Go 1.19+. If Go is new to you, start with the complete Go tutorial. The Go generics guide is the pillar for this learning path, while the type-system guide provides useful background for the value and pointer semantics behind escape analysis.

A note on the numbers: everything ran on a shared 2-vCPU cloud VM (Intel Xeon at 2.10 GHz, GOMAXPROCS=2). Your absolute figures will differ. What holds across machines is the relative result: which version allocates less, and by how many times.

Why Go has a garbage collector and what it optimizes for

In C you call free yourself. Forget one and you leak; free twice and you corrupt the heap. Go removes that whole class of bug by tracking which memory is still reachable and reclaiming the rest automatically. You allocate; the runtime frees.

The interesting design decision is what Go optimizes the collector for. A collector can chase peak throughput (get the most application work done per CPU-second) or low latency (keep individual pauses short). Go chose latency. The official GC guide puts it directly: the Go GC “avoids making the length of any global application pauses proportional to the size of the heap, and the core tracing algorithm is performed while the application is actively executing.”

That trade has a cost, and the guide states it directly: collecting concurrently “often leads to a design with lower throughput than an equivalent stop-the-world garbage collector.” Go accepts a bit less raw throughput so that a service handling requests does not freeze while the heap is scanned. For the servers Go is built for, predictable tail latency is worth more than a few percent of throughput. That priority explains the design choices below.

The concurrent tri-color mark-and-sweep collector in plain language

Go’s collector is a concurrent, non-moving, tri-color mark-and-sweep collector. Take that apart.

Mark and sweep is two phases. In the mark phase the GC starts from the roots (goroutine stacks, global variables) and follows every pointer it can reach, marking each object it lands on as live. In the sweep phase it walks the heap and returns everything not marked to the allocator for reuse. Reachable means live; unreachable means garbage. That is the entire idea.

Tri-color is how the mark phase tracks progress so it can run alongside your program. Every object is conceptually white, grey, or black:

  • White: not yet proven reachable. At the end, whatever is still white is garbage.
  • Grey: reachable, but its own pointers have not been scanned yet.
  • Black: reachable, and all its pointers have been scanned.

The GC greys the roots, then repeatedly takes a grey object, blackens it, and greys everything it points to. When no grey objects remain, marking is done and every white object is dead.

Concurrent is the hard part. Your goroutines keep running and mutating pointers while the GC marks. That opens a hole: a black object (already scanned) could be pointed at a white object, while the only other reference to that white object is deleted. The GC would sweep something still in use. Go closes the hole with a write barrier, a small piece of code the compiler injects around pointer writes during marking. When you write a pointer mid-cycle, the barrier makes sure the target is greyed, so nothing live is ever left white. The GC guide lists exactly this cost: “pointer writes requiring additional work while the GC is in the mark phase.”

Non-moving means Go never relocates a live object to compact the heap. Its address is stable for its whole life, which keeps pointers (including those shared with C via cgo) valid and avoids a pointer-rewriting pass.

The only stop-the-world (STW) pauses are two short ones that bracket the concurrent mark: one to turn the write barrier on, one to turn it off and finish. Those pauses do not scale with heap size, which is the whole point. You will see their real length in the gctrace output later; they are the sub-millisecond numbers.

Stack versus heap: escape analysis decides where your values live

The GC only manages the heap. Anything the compiler can prove lives and dies inside one function call goes on that goroutine’s stack instead, and stack memory is freed for free when the function returns. No GC involvement, no marking, no sweeping. So the cheapest allocation is the one that never touches the heap.

The compiler decides this with escape analysis: if a value can still be referenced after its function returns, it “escapes” to the heap; otherwise it stays on the stack. You do not annotate anything, but you can see the decision. Here are two functions that look almost identical:

package main

import "fmt"

type User struct {
	ID   int
	Name string
}

// buildOnStack keeps the User local. Nothing escapes, so it stays on the stack.
func buildOnStack(id int) string {
	u := User{ID: id, Name: "guest"}
	return u.Name
}

// buildOnHeap returns a pointer to the User, so the value must outlive the
// call. Escape analysis moves it to the heap.
func buildOnHeap(id int) *User {
	u := User{ID: id, Name: "guest"}
	return &u
}

func main() {
	fmt.Println(buildOnStack(1), buildOnHeap(2).Name)
}

Ask the compiler what it decided with go build -gcflags='-m -l' (the -l disables inlining so the output is readable):

$ go build -gcflags='-m -l' escape.go
./escape.go:19:2: moved to heap: u
./escape.go:24:13: ... argument does not escape
./escape.go:24:26: buildOnStack(1) escapes to heap
./escape.go:24:45: buildOnHeap(2).Name escapes to heap

The line that matters is moved to heap: u at line 19, which is the u inside buildOnHeap. That value escaped because you returned its address. The u in buildOnStack is never mentioned, which means it stayed on the stack and cost the GC nothing. (The two “escapes to heap” notes on line 24 are just about the arguments handed to fmt.Println, a separate and unavoidable thing.)

This is the most useful GC skill you can build, and it costs nothing at runtime. Returning *User from a constructor is a normal, idiomatic choice; just know it is a heap allocation the GC will later track. When you see allocation counts you want to cut, -gcflags=-m tells you exactly which lines escaped and why.

GOGC and the pacing model: trading memory for CPU

The runtime does not collect on a timer. It collects based on how much the heap has grown, and GOGC sets the threshold. The GC guide gives the formula:

Target heap = Live heap + (Live heap + roots) * GOGC / 100

With the default GOGC=100, the runtime lets the heap roughly double past the live set before it triggers the next cycle. Set it to 200 and you allow more growth between collections; set it to 50 and you collect twice as often. The guide states the trade in one sentence: “doubling GOGC will double heap memory overheads and roughly halve GC CPU cost.”

That is the whole lever. More memory headroom buys fewer GC cycles (less CPU spent collecting); less headroom saves memory but spends more CPU. You can watch it move. This program retains a growing slice of records, so the live heap climbs steadily:

package main

import "fmt"

type record struct {
	id   int
	data []byte
}

func main() {
	kept := make([]*record, 0, 4096)
	for i := 0; i < 200000; i++ {
		r := &record{id: i, data: make([]byte, 512)}
		kept = append(kept, r)
	}
	fmt.Println("records retained:", len(kept))
}

Run it at three GOGC settings with GC tracing on and count the cycles:

$ GODEBUG=gctrace=1 GOGC=50  ./prog 2>&1 | grep -c '^gc'
11
$ GODEBUG=gctrace=1 GOGC=100 ./prog 2>&1 | grep -c '^gc'
5
$ GODEBUG=gctrace=1 GOGC=400 ./prog 2>&1 | grep -c '^gc'
2
$ GODEBUG=gctrace=1 GOGC=off ./prog 2>&1 | grep -c '^gc'
0

Same work, same allocations, but GOGC=50 collected 11 times and GOGC=400 only twice. GOGC=off disabled the collector entirely (fine for a short batch job that exits before it runs out of memory, dangerous for a long-lived service). You set this with the GOGC environment variable or debug.SetGCPercent at runtime.

GOMEMLIMIT: a soft memory ceiling for containers (Go 1.19+)

GOGC has one blind spot: it is relative to the live heap, so if your live set spikes, the heap target spikes with it, and in a container with a hard memory cap that means an OOM kill. Go 1.19 added GOMEMLIMIT to fix exactly this. It sets a soft limit on total memory the runtime will use, and the GC runs more aggressively as usage approaches it, regardless of GOGC.

“Soft” is the key word, and the guide is blunt: the runtime “makes no guarantees that it will maintain this memory limit under all circumstances; it only promises some reasonable amount of effort.” It will not free memory that is genuinely still live. To avoid a death spiral where the GC burns all your CPU fighting a limit it cannot meet, the runtime caps GC at roughly 50% of CPU over a rolling window and lets the limit be exceeded rather than thrash forever.

You can see the limit take over. Run the same retaining program with GOGC off but a 64 MiB limit set:

$ GODEBUG=gctrace=1 GOGC=off GOMEMLIMIT=64MiB ./prog 2>&1 | tail -3
gc 7 @0.065s 15%: 0.010+6.7+0.050 ms clock, ..., 87->97->97 MB, 87 MB goal, ...
gc 8 @0.077s 14%: 0.010+6.9+0.062 ms clock, ..., 97->104->104 MB, 97 MB goal, ...
gc 9 @0.090s 13%: 0.097+5.5+0.003 ms clock, ..., 104->104->102 MB, 104 MB goal, ...

With GOGC off, this program did zero collections earlier. With the memory limit set, the GC ran nine times trying to hold the line, its goal now driven by the limit instead of by heap growth. Notice the live heap still climbed past 64 MiB to ~102 MB: the data is retained and cannot be freed, which shows the soft limit avoiding destructive thrashing rather than guaranteeing a hard ceiling.

The practical rule from the guide: set GOMEMLIMIT when your Go process is the main thing in a container with a known memory budget, and leave 5 to 10% headroom for memory the runtime does not account for. A common production setup is GOGC=off (or a high value) plus a GOMEMLIMIT at ~90% of the container limit, so the heap grows freely for throughput but the limit is the hard backstop. Do not set it for CLI tools or anything sharing memory with processes you do not control.

Reading GC behavior: GODEBUG=gctrace=1 and runtime.ReadMemStats

Do not guess at GC behavior. Measure it. Two built-in tools cover almost everything.

GODEBUG=gctrace=1 prints one line per GC cycle to stderr, no code changes needed. Here is the default GOGC=100 run of the retaining program:

$ GODEBUG=gctrace=1 ./prog
gc 1 @0.000s 15%: 0.12+0.45+0.003 ms clock, ..., 3->4->3 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 2 P
gc 2 @0.002s 17%: 0.009+0.78+0.036 ms clock, ..., 6->7->7 MB, 7 MB goal, ...
gc 3 @0.004s 15%: 0.031+1.1+0.083 ms clock, ..., 12->13->12 MB, 14 MB goal, ...
gc 4 @0.008s 13%: 0.052+2.6+0.061 ms clock, ..., 21->26->26 MB, 25 MB goal, ...
gc 5 @0.019s 11%: 0.010+3.7+0.052 ms clock, ..., 44->47->45 MB, 52 MB goal, ...
gc 6 @0.029s 8%:  0.011+6.8+0.049 ms clock, ..., 77->88->86 MB, 91 MB goal, ...

Read one line. Take gc 6: @0.029s is time since start; 8% is the share of total CPU spent in GC so far. The clock triple 0.011+6.8+0.049 ms is the three phases: STW sweep termination (0.011 ms), concurrent mark (6.8 ms, running alongside your program), STW mark termination (0.049 ms). Your program only actually paused for 0.011 + 0.049 ms, about 60 microseconds, even though the cycle spanned 6.8 ms. That is the concurrent design paying off. The 77->88->86 MB is heap size at GC start, at GC end, and the live set after sweep; 91 MB goal is the target that triggered it.

Follow the goal down the column: 4, 7, 14, 25, 52, 91 MB. It roughly doubles each cycle, tracking the growing live heap at GOGC=100. That is the pacing model from the previous section, visible in real output.

For numbers inside your program, runtime.ReadMemStats fills a struct you can log or export:

package main

import (
	"fmt"
	"runtime"
)

func main() {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("before: HeapAlloc=%d KiB, Mallocs=%d, NumGC=%d\n",
		m.HeapAlloc/1024, m.Mallocs, m.NumGC)

	kept := make([][]byte, 0, 10000)
	for i := 0; i < 10000; i++ {
		kept = append(kept, make([]byte, 1024))
	}

	runtime.ReadMemStats(&m)
	fmt.Printf("after:  HeapAlloc=%d KiB, Mallocs=%d, NumGC=%d\n",
		m.HeapAlloc/1024, m.Mallocs, m.NumGC)
	fmt.Printf("total bytes ever allocated: %d KiB\n", m.TotalAlloc/1024)
	_ = kept
}
before: HeapAlloc=57 KiB, Mallocs=225, NumGC=0
after:  HeapAlloc=10297 KiB, Mallocs=10238, NumGC=2
total bytes ever allocated: 10298 KiB

HeapAlloc is currently reachable heap, TotalAlloc only ever grows (bytes allocated over the whole run), and NumGC counts completed cycles. ReadMemStats briefly stops the world, so call it on a schedule, not in a hot loop.

Reducing GC pressure in practice

You rarely need to tune GOGC. Reducing allocations often improves latency more directly because the GC has less to trace and runs less often. Four techniques cover most cases, each measured below with go test -bench=. -benchmem. If benchmarking is new, see the Go benchmarking guide for how to read ns/op, B/op, and allocs/op.

Preallocate slices with a capacity hint. Appending to a nil slice reallocates the backing array as it grows, copying every time:

func buildNoCap(n int) []int {
	var s []int // grows by repeated reallocation
	for i := 0; i < n; i++ {
		s = append(s, i)
	}
	return s
}

func buildWithCap(n int) []int {
	s := make([]int, 0, n) // capacity known: one allocation
	for i := 0; i < n; i++ {
		s = append(s, i)
	}
	return s
}
BenchmarkAppendNoCap-2      150903     8012 ns/op    25208 B/op    12 allocs/op
BenchmarkAppendWithCap-2    357264     3224 ns/op     8192 B/op     1 allocs/op

One make with the right capacity turned 12 allocations into 1 and cut time by 60%. When you know the size, say so.

Reuse buffers with sync.Pool. For temporary objects you build and throw away on every request (a byte buffer, a scratch struct), sync.Pool hands back a previously used one instead of allocating fresh:

var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

func formatRowsPooled(n int) []byte {
	buf := bufPool.Get().(*bytes.Buffer)
	buf.Reset()
	defer bufPool.Put(buf)
	// ... write into buf ...
	return append([]byte(nil), buf.Bytes()...)
}
BenchmarkFormatRows-2         652069    1830 ns/op    2688 B/op    6 allocs/op
BenchmarkFormatRowsPooled-2  1000000    1309 ns/op     704 B/op    1 allocs/op

The pooled version dropped from 6 allocations to 1. sync.Pool is worth it for hot paths that allocate the same short-lived object over and over; it is not a general object cache.

Prefer value semantics over a slice of pointers. A []*Point is one allocation for the slice plus one for every element. A []Point packs the elements into a single backing array:

BenchmarkSliceOfPointers-2    59563    21432 ns/op    32192 B/op    1001 allocs/op
BenchmarkSliceOfValues-2     196852     6994 ns/op    24576 B/op       1 allocs/op

Storing 1000 points as values instead of pointers went from 1001 allocations to 1, and ran 3x faster. Pointers earn their keep when the struct is large or you need to mutate shared state; for small value types you copy around, values keep the GC out of it. If you are unsure which semantics you want, the Go type-system guide explains the underlying value behavior.

Cutting allocations in an application function

Here is the workflow for a function that builds a receipt string from line items. The first version uses += concatenation and a fmt.Sprintf call per line:

func buildReceiptSlow(items []LineItem) string {
	out := ""
	for _, it := range items {
		out += fmt.Sprintf("%s x%d = %d\n", it.SKU, it.Qty, it.Qty*it.Price)
	}
	return out
}

Two things allocate hard here. += on a string builds a brand-new string every iteration (strings are immutable, so each concat copies), and fmt.Sprintf allocates its result plus boxes each argument into an interface{}. The rewrite uses one strings.Builder with a capacity hint and strconv.Itoa instead of the reflection-based Sprintf:

func buildReceiptFast(items []LineItem) string {
	var b strings.Builder
	b.Grow(len(items) * 24)
	for _, it := range items {
		b.WriteString(it.SKU)
		b.WriteString(" x")
		b.WriteString(strconv.Itoa(it.Qty))
		b.WriteString(" = ")
		b.WriteString(strconv.Itoa(it.Qty * it.Price))
		b.WriteByte('\n')
	}
	return b.String()
}

Benchmark both over 50 line items:

BenchmarkReceiptSlow-2    78236    15467 ns/op    21474 B/op    189 allocs/op
BenchmarkReceiptFast-2   586887     1931 ns/op     1440 B/op     51 allocs/op

189 allocations down to 51, and 8x faster, from two changes: stop copying the whole string on every +=, and stop routing every number through fmt. The remaining 51 come mostly from strconv.Itoa returning a new string per call; if this were truly hot you would format with strconv.AppendInt into a byte slice and drop most of those too. The method is always the same: benchmark with -benchmem, find the allocations, use -gcflags=-m to confirm what escapes, remove one source at a time, benchmark again.

Common mistakes

Tuning GOGC before measuring. Reaching for GOGC=200 because you read it helps is backwards. Profile first with gctrace and pprof. If the GC is not actually your bottleneck (it usually is not), turning the knob just trades away memory for nothing. Tune the code’s allocations before you tune the collector.

Fighting the GC instead of feeding it less. The GC’s workload is a function of how much you allocate. Elaborate schemes to “help” it (manual free lists everywhere, forcing runtime.GC() on a timer) usually make things slower and buggier. runtime.GC() is a synchronous, stop-the-world collection; calling it in normal request handling adds pauses instead of removing them. Reduce allocations and the GC gets quiet on its own.

Misusing sync.Pool. A pool is for objects with a short, bounded lifetime that you can safely reset. Two failure modes: putting back an object you still hold a reference to (it gets reused underneath you, corrupting data), and pooling objects that live a long time (they just pin memory the GC would otherwise reclaim). Always Reset() on Get, never keep the reference after Put, and only pool genuinely transient objects.

Ignoring escape analysis. People add sync.Pool and capacity hints while a hot constructor quietly returns a pointer that escapes on every call. Run go build -gcflags=-m on the hot path first; the cheapest allocation to optimize is the one you can keep on the stack entirely.

Practical expectations: you rarely need to tune

Set your expectations from what the collector actually delivers. Stop-the-world pauses in a normal service are sub-millisecond; in the traces above they were tens of microseconds while the multi-millisecond marking ran concurrently. The GC targets about 25% of CPU during a cycle and paces itself to stay there. For the vast majority of Go programs the correct amount of GC tuning is zero: you leave GOGC at 100, maybe set GOMEMLIMIT in a container, and spend your effort on not allocating in hot paths.

The cases that justify tuning are narrow: a latency-critical service where you have already cut allocations and need the last few percent, or a batch job where trading memory for throughput with a higher GOGC has been measured to help. Elsewhere, write clear code, monitor allocs/op in benchmarks, and let the collector use its defaults.

What next

  • Go’s type system: value and pointer semantics influence many escape-analysis outcomes.
  • Go benchmarking with testing.B: the -benchmem workflow used throughout this article, in full, so you can measure your own allocations.
  • Goroutines tutorial: goroutine stacks are GC roots and can pin memory; understanding scheduling helps you spot leaks the collector cannot fix.
  • The complete Go tutorial: the foundation, if you want the language basics under all of this.