Memory and the runtime
Go is a garbage-collected language. The runtime manages memory automatically: values that are no longer reachable are reclaimed by the garbage collector. The Go memory model is conventional for a managed language — heap and stack allocations, automatic reclamation, no manual free — but with two distinguishing features: escape analysis admits substantial stack allocation (substantially reducing GC pressure compared with always-heap-allocated languages), and the GC is concurrent and low-latency (typically sub-millisecond pause times). The combination — automatic management, escape analysis, concurrent GC, value semantics with explicit pointer indirection — is the substance of Go’s memory model.
This page covers the conventional understanding a working programmer needs. The detailed runtime mechanics are documented in the Go runtime source.
The two memory regions
Like most managed languages, Go has two principal memory regions:
- Stack — per-goroutine, LIFO, automatically reclaimed when functions return.
- Heap — shared across goroutines, managed by the garbage collector.
Unlike C, the language does not require the programmer to choose where a value lives. The compiler’s escape analysis makes that decision based on whether a value escapes its enclosing function.
Escape analysis
The compiler determines whether each value can live on the stack or must escape to the heap:
// Stays on the stack:
func makePoint() Point {
return Point{X: 1, Y: 2} // returned by value; can stay on stack
}
// Escapes to the heap:
func makePointPtr() *Point {
return &Point{X: 1, Y: 2} // returned by pointer; must escape
}
// Stays on the stack:
func usePoint() float64 {
p := Point{X: 1, Y: 2} // local; not escaping
return p.X * p.Y
}
// Escapes:
func storePoint() {
p := Point{X: 1, Y: 2}
points = append(points, &p) // pointer stored elsewhere; escapes
}
To inspect the compiler’s analysis:
go build -gcflags='-m' main.go
The output identifies which allocations escape and which can be stack-allocated:
./main.go:5:9: &Point{...} escapes to heap
./main.go:10:9: p does not escape
The mechanism admits substantial efficiency: short-lived values stay on the stack, where allocation is essentially free; only genuinely shared or returned values pay the GC cost.
The garbage collector
Go’s GC is tracing and concurrent:
- Tracing — it identifies live values by following pointers from a known set of roots (goroutine stacks, globals).
- Concurrent — it runs concurrently with application code, with brief stop-the-world pauses.
- Non-generational — Go’s GC does not partition the heap by age (most other major GCs do).
- Mark-and-sweep — it marks live values, then sweeps unreferenced memory back to the allocator.
The GC is tuned for low pause times (typically under a millisecond) at the cost of some throughput. The conventional Go application pays approximately 25% CPU overhead for GC under heavy allocation; tuning admits trade-offs.
GOGC
The GOGC environment variable controls how aggressively the GC runs:
GOGC=100 # default: GC when heap is 2× the live set
GOGC=200 # less aggressive; more memory, less CPU
GOGC=50 # more aggressive; less memory, more CPU
GOGC=off # disable GC (rarely useful)
The conventional default is fine for most applications; tuning is rarely needed.
GOMEMLIMIT
Since Go 1.19, the GOMEMLIMIT environment variable admits a soft memory limit:
GOMEMLIMIT=8GiB # collect more aggressively as we approach 8 GiB
The mechanism admits running Go programs in containers with limited memory; the GC behaves more aggressively as the limit is approached.
Triggering the GC
The runtime package admits manual GC operations:
import "runtime"
runtime.GC() // force a full GC
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Println(stats.HeapAlloc) // bytes currently allocated
fmt.Println(stats.NumGC) // number of GC runs
fmt.Println(stats.PauseTotalNs) // total time spent paused for GC
The conventional discipline avoids manual GC calls — the runtime decides better than the programmer when to collect.
Stack growth
Go’s goroutine stacks start small (typically 8 KiB as of Go 1.4+) and grow as needed:
- A goroutine begins with a small stack.
- When the function call depth exceeds the stack, the runtime allocates a larger stack and copies the existing frames.
- The mechanism admits running millions of goroutines without millions × 1 MiB of memory consumption.
The growth is handled automatically; the conventional Go program is unaware of it. Deep recursion eventually fails with a stack-overflow panic, but the limit is configurable (runtime/debug.SetMaxStack).
Allocation primitives
The principal allocation forms:
// Composite literal — admits heap or stack:
p := Point{X: 1, Y: 2} // value
pp := &Point{X: 1, Y: 2} // pointer (typically heap)
// new — always returns a pointer:
p := new(Point) // *Point pointing at zero-valued Point
// make — for slices, maps, channels:
s := make([]int, 10) // slice
m := make(map[string]int) // map
ch := make(chan int) // channel
The form to use:
T{...}for struct values.&T{...}for struct pointers (the conventional “constructor” form).makefor slices, maps, channels.newrarely used; the&T{}form is conventional.
Slice memory model
A slice has three components: a pointer to the backing array, a length, and a capacity:
┌──────────────┐
│ pointer │ → [a, b, c, d, e, f, g, h]
│ length: 3 │ ↑ ↑
│ capacity: 8 │ │ length
└──────────────┘ pointer
append produces a new slice; if the capacity is sufficient, the slice shares the backing array; otherwise a new (larger) array is allocated:
s := []int{1, 2, 3}
fmt.Printf("len=%d cap=%d\n", len(s), cap(s)) // len=3 cap=3
s = append(s, 4)
fmt.Printf("len=%d cap=%d\n", len(s), cap(s)) // len=4 cap=6 (or similar; grew)
s2 := s[:3] // shares backing array
s2[0] = 999 // mutates s as well
The shared backing array can produce surprising aliasing:
s := []int{1, 2, 3, 4, 5}
s2 := s[1:3] // [2, 3]
s2[0] = 99 // s is now [1, 99, 3, 4, 5]
The conventional defences:
- Use
slices.Clone(Go 1.21+) for an independent copy. - Use
append([]int{}, s...)for an independent copy. - Use
copy(dst, src)to copy contents.
Map memory model
A map is a hash table with the following characteristics:
- Buckets are arrays containing several entries each.
- Hash collisions are handled by chaining buckets.
- The map grows by approximately doubling when load is high.
- Map iteration order is randomised — the language deliberately produces different orders across runs.
Maps are reference-like; passing a map admits in-place mutation:
func addEntry(m map[string]int, key string, value int) {
m[key] = value // caller's map is mutated
}
A nil map admits read access (returning zero value) but panics on write:
var m map[string]int // nil map
v := m["key"] // OK; returns 0
// m["key"] = 1 // PANIC: assignment to entry in nil map
The conventional defence is to initialise maps explicitly with make.
String memory model
A string has two components: a pointer to the bytes and a length:
┌──────────────┐
│ pointer │ → [h, e, l, l, o]
│ length: 5 │
└──────────────┘
Strings are immutable — the bytes cannot be modified through the string. The mechanism admits sharing without copying:
s := "hello world"
sub := s[:5] // "hello" — shares the backing bytes
// No copy is made
Conversion between string and []byte does copy (since slices admit mutation):
s := "hello"
b := []byte(s) // copy
b[0] = 'H' // safe; doesn't affect s
fmt.Println(s) // "hello"
The conventional defence for performance-critical code is to use []byte throughout if mutation is needed; the conversions cost.
Goroutines and memory
Each goroutine has its own stack but shares the heap with other goroutines. The shared heap requires synchronisation for shared mutable state — data races produce undefined behaviour:
var counter int
go func() { counter++ }() // RACE: concurrent write
go func() { fmt.Println(counter) }() // RACE: concurrent read
// Defences:
import "sync"
import "sync/atomic"
var mu sync.Mutex
mu.Lock()
counter++
mu.Unlock()
// Or:
var c atomic.Int64
c.Add(1)
fmt.Println(c.Load())
The race detector (go run -race ..., go test -race ...) admits dynamic detection of races; treated in Concurrency.
Finalisers
The runtime.SetFinalizer admits attaching a function to be called when an object is garbage-collected:
import "runtime"
f := openResource()
runtime.SetFinalizer(f, func(f *Resource) {
f.Close()
})
The mechanism is rarely used in idiomatic Go; the conventional alternative is defer for explicit cleanup:
f, err := os.Open("file.txt")
if err != nil {
return err
}
defer f.Close() // close on function return
The conventional discipline is to manage resources explicitly with defer; finalisers are best avoided.
sync.Pool
For high-allocation workloads, sync.Pool admits reusing values:
import "sync"
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func process(s string) string {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufPool.Put(buf)
}()
buf.WriteString(s)
return buf.String()
}
The mechanism reduces GC pressure for short-lived, frequently-allocated values. The conventional discipline is to use sync.Pool only after profiling shows allocation as a bottleneck.
Profiling
Go’s standard library admits substantial profiling:
import _ "net/http/pprof"
// Then access http://localhost:6060/debug/pprof/ for profiles
CPU and memory profiles can be captured and analysed with go tool pprof:
go tool pprof http://localhost:6060/debug/pprof/heap
go tool pprof http://localhost:6060/debug/pprof/profile
The mechanism admits substantial visibility into allocation patterns and GC pressure. Treated in Standard library and Concurrency.
Common patterns
Reduce allocations in hot paths
// Allocates each call:
func process(s string) string {
return strings.ToUpper(s) + " processed"
}
// Reuses a buffer:
var buf strings.Builder
func process(s string) string {
buf.Reset()
buf.WriteString(strings.ToUpper(s))
buf.WriteString(" processed")
return buf.String()
}
The pattern reduces allocations; trade-off is loss of concurrency safety for the buffer.
Avoid string-to-byte conversions
// Unnecessary copy:
b := []byte(s)
return f(b)
// If f admits a string:
return f(s)
The conventional discipline is to keep data as string or []byte consistently rather than converting back and forth.
Pool for short-lived buffers
var bufferPool = sync.Pool{
New: func() interface{} {
return make([]byte, 0, 4096)
},
}
func encode(data []byte) string {
buf := bufferPool.Get().([]byte)
defer bufferPool.Put(buf[:0])
/* ... use buf ... */
return result
}
Pre-allocate slices when size is known
// Inefficient (multiple grows):
result := []int{}
for _, x := range data {
result = append(result, transform(x))
}
// Efficient (single allocation):
result := make([]int, 0, len(data))
for _, x := range data {
result = append(result, transform(x))
}
Avoid pointer-heavy data structures
// Lots of pointer chasing for the GC:
type Tree struct {
Children []*Tree
}
// Single contiguous allocation:
type Tree struct {
children []Tree // value-type children
}
The conventional discipline is to avoid pointer-heavy structures when the GC is a bottleneck; profile before optimising.
A note on the conventional discipline
The contemporary Go memory advice:
- Trust the garbage collector — manual memory management is not admitted.
- Use
deferfor resource cleanup. - Trust escape analysis — the compiler decides stack vs heap.
- Pre-allocate slices when size is known.
- Use
makewith capacity for slices and maps. - Use
sync.Poolonly when profiling indicates allocation pressure. - Use the race detector in tests.
- Profile before optimising —
go tool pprofis the conventional analysis tool. - Avoid finalisers — use
deferfor explicit cleanup.
The combination — automatic GC, escape analysis, concurrent collection, low pause times, profiling tooling — is the substance of Go’s memory story. The conventional discipline produces correct, performant code with substantial automation.