The GC is a background worker. Respect its rhythm.
You write a Go service that processes images. You allocate buffers, decode pixels, resize, and send the result. In C, you'd spend half your time tracking which buffer to free and when. In Go, you just let the variables go out of scope. The memory disappears. You forget about it. Until the latency spikes on a Tuesday afternoon and you realize the garbage collector is fighting your CPU.
The Go garbage collector (GC) is a concurrent, tri-color mark-and-sweep system. That jargon means it cleans memory while your program runs, uses three colors to track object states, and sweeps away what's dead. Think of it like cleaning a house while people are still moving around. You can't just throw everything away. You have to mark the stuff being used, ignore the marked stuff, and toss the rest. The concurrent part means the cleaners work alongside the residents, not during a lockdown. The tri-color part is the tracking method: white for dead, black for live, gray for "we're checking this."
Escape analysis decides stack versus heap
Before the GC ever runs, the compiler makes a critical decision. Every variable lives on either the stack or the heap. Stack memory is fast and automatic. When a function returns, the stack pointer moves and the memory is gone. No GC needed. Heap memory persists beyond the function call. The GC must track and reclaim it.
The compiler performs escape analysis to decide where each variable goes. If a variable's lifetime is contained within the function, it stays on the stack. If a pointer to the variable escapes the function, or if the variable is too large for the stack, it moves to the heap. This analysis happens at compile time. It's free. You can see the decisions by running go build -gcflags="-m". The compiler prints notes like moves x to heap or does not escape.
You don't control escape analysis directly. You influence it by how you structure your code. Returning a pointer forces the pointed-to value to escape. Passing a pointer to a goroutine forces escape. Large arrays often escape because the stack has size limits. Small structs usually stay on the stack. Trust the compiler. It's usually right.
Minimal example: allocate, drop, collect
Here's the simplest interaction with the GC: allocate memory, drop the reference, and watch the stats.
package main
import (
"fmt"
"runtime"
)
// allocateAndForget creates a slice and lets it go out of scope.
func allocateAndForget() {
// Allocate a large slice on the heap.
// Go decides heap vs stack based on escape analysis.
// This slice escapes because it's too large for the stack frame.
data := make([]byte, 1024*1024) // 1MB slice
// Use the data to prevent compiler optimization.
// If unused, the compiler might delete the allocation entirely.
data[0] = 42
// data goes out of scope here.
// The GC will eventually reclaim this memory.
// It's now "white" and eligible for collection.
}
func main() {
// Run the allocation function.
allocateAndForget()
// Force a GC cycle for demonstration.
// In production, the GC runs automatically based on GOGC.
// Calling runtime.GC() blocks the goroutine until the cycle finishes.
runtime.GC()
// Print stats to see the effect.
var m runtime.MemStats
runtime.ReadMemStats(&m)
// fmt.Printf is used to print stats.
fmt.Printf("Alloc: %v KB\n", m.Alloc/1024)
}
When you run this, the compiler moves the 1MB slice to the heap. allocateAndForget returns, and the local variable data vanishes. The heap object has no references. The GC marks it white. When runtime.GC() runs, the GC threads scan the heap. They find the white object, confirm no black object points to it, and sweep it. The memory returns to the pool. The Alloc stat drops.
Write barriers keep the GC accurate
The GC runs concurrently with your program. Your code is allocating, updating pointers, and running logic while the GC is marking objects. How does the GC know about pointer changes without stopping the world? Write barriers.
A write barrier is a tiny piece of code the compiler inserts whenever your program writes to a pointer field. When you update a pointer, the barrier executes before or after the write. It tells the GC "hey, this reference changed." If the destination object is white, the barrier marks it gray. This ensures the GC doesn't miss new references during the mark phase.
The write barrier maintains a critical invariant: no black object points to a white object. If this holds, all white objects are truly dead. The GC can safely sweep them. The barrier has a small cost, but it's necessary for concurrency. You don't see the barriers. The compiler handles them. But they're why the GC can run without long pauses.
Realistic example: HTTP handler under load
Real code doesn't call runtime.GC(). It runs under load. Consider an HTTP handler that reads a request body, processes it, and returns a response.
package main
import (
"io"
"net/http"
)
// handleUpload processes a request body and discards it.
// This simulates a handler that reads data and lets it go.
func handleUpload(w http.ResponseWriter, r *http.Request) {
// Read the body into a buffer.
// io.ReadAll allocates a slice large enough for the body.
// This allocation lives on the heap if the body is large.
body, err := io.ReadAll(r.Body)
if err != nil {
// Return error response.
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Process the body.
// In a real app, you might parse JSON or resize an image.
// Here we just compute a length to use the data.
length := len(body)
// body goes out of scope after this function returns.
// The GC can reclaim the buffer memory for the next request.
// This reuse is key to keeping memory pressure low.
w.WriteHeader(http.StatusOK)
w.Write([]byte("Processed"))
_ = length
}
func main() {
// Register the handler.
http.HandleFunc("/upload", handleUpload)
// Start the server.
// The GC runs in the background while requests arrive.
http.ListenAndServe(":8080", nil)
}
Here's a realistic HTTP handler that allocates memory per request and relies on the GC to clean up between calls. Each request allocates a buffer. The buffer escapes to the heap. The handler returns. The buffer becomes white. The GC eventually sweeps it. Under high load, the GC runs frequently to keep up with allocations. The GOGC environment variable controls the trigger. It defaults to 100, meaning the GC runs when the heap doubles since the last collection. This balances latency and throughput for most workloads.
Pitfalls: Cgo, finalizers, and leaks
The GC isn't magic. It has rules. If you break them, memory leaks or panics happen.
C code is invisible to the GC. If you allocate memory in C and pass a pointer to Go, the GC doesn't know it exists. If Go holds the pointer, the GC keeps the Go object alive, but the C memory leaks. You need runtime.SetFinalizer to bridge the gap. The finalizer runs when the GC collects the Go object. It's not deterministic. Don't rely on it for critical cleanup. Use it for C resources.
If you forget to free C memory, you get a leak. The compiler won't stop you. The runtime won't panic. Your process just grows until the OOM killer stops it. The compiler rejects programs with undefined variables, but it can't detect C leaks. You must track them yourself.
Goroutine leaks are another danger. A goroutine waiting on a channel that never closes is a leak. The GC can't collect the goroutine's stack or its local variables. Always provide a cancellation path. Use context.Context to signal goroutines to stop. The context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. If a goroutine holds a reference to a large buffer, and the context cancels, the goroutine should drop the buffer. The GC can then reclaim it.
The worst goroutine bug is the one that never logs. Leaks accumulate silently. Profile your program to catch them.
Convention asides
Go has community conventions that pay off. gofmt is mandatory. Don't argue about indentation; let the tool decide. Most editors run it on save. Trust gofmt. Argue logic, not formatting.
Error handling is verbose by design. if err != nil { return err } is boilerplate, but it makes the unhappy path visible. The community accepts the verbosity because it prevents silent failures. The GC helps you avoid use-after-free bugs, but you still need to check errors.
Receiver names are usually one or two letters matching the type. (b *Buffer) Write(...) is standard. (this *Buffer) or (self *Buffer) is not Go style. Public names start with a capital letter. Private names start lowercase. No keywords like public or private. Interfaces are accepted, structs are returned. "Accept interfaces, return structs" is the most common Go style mantra.
Don't pass a *string. Strings are already cheap to pass by value. The GC doesn't need to track string pointers separately. Passing by value is faster and simpler.
Reduce GC pressure with pools
When you allocate and discard short-lived objects rapidly, the GC has to work hard. sync.Pool reduces pressure by reusing memory. The pool holds objects that are no longer needed. When you need an object, you get one from the pool instead of allocating. When you're done, you return it. The GC can still collect the pool if the pool itself is unreachable, but active objects stay alive.
Here's how sync.Pool reduces GC pressure by reusing memory across requests.
package main
import (
"sync"
)
// bufferPool reuses byte slices to avoid GC pressure.
// This is useful for high-throughput servers that allocate buffers per request.
var bufferPool = sync.Pool{
// New is called when the pool is empty.
// It allocates a fresh buffer for the first user.
New: func() interface{} {
// Allocate a 4KB buffer.
// This size matches common network packet sizes.
return make([]byte, 4096)
},
}
// getBuffer retrieves a buffer from the pool.
// If the pool has idle buffers, it returns one immediately.
// This avoids heap allocation and GC work.
func getBuffer() []byte {
// Get returns an interface{}, so we must type assert.
// The pool stores values as interface{} to hold any type.
return bufferPool.Get().([]byte)
}
// putBuffer returns a buffer to the pool.
// The buffer is available for the next caller without reallocation.
// This keeps memory usage stable under load.
func putBuffer(buf []byte) {
// Put stores the buffer for reuse.
// The GC can still collect the pool if the pool itself is unreachable.
bufferPool.Put(buf)
}
Pools shine when allocation cost is high and reuse is frequent. They don't eliminate GC work entirely. The pool itself lives on the heap. But they reduce the rate of allocation and collection. Use pools for buffers, connections, and other expensive resources.
Decision: when to use this versus alternatives
Use the default GC settings when you have a standard Go application. The GOGC environment variable defaults to 100, which triggers collection when heap doubles. This balances latency and throughput for most workloads.
Use runtime/debug.SetGCPercent when you need to tune collection frequency at runtime. Lower the percentage to collect more often and reduce peak memory usage. Raise it to reduce CPU overhead during bursty allocations.
Use runtime.SetFinalizer when you must free C-allocated resources tied to a Go object. The finalizer runs when the GC collects the object, bridging the gap between managed and unmanaged memory.
Use manual runtime.GC() only for testing or benchmarking. Production code should rely on the automatic collector. Forcing a cycle blocks the goroutine and disrupts the GC's internal pacing.
Use object pooling with sync.Pool when you allocate and discard short-lived objects rapidly. The pool reuses memory without GC pressure, reducing both allocation cost and collection work.
Tune the GC only when metrics tell you to. Default settings work for 90% of apps.