Polyglot
Languages Go memory
Go § memory

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).
  • make for slices, maps, channels.
  • new rarely 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 defer for resource cleanup.
  • Trust escape analysis — the compiler decides stack vs heap.
  • Pre-allocate slices when size is known.
  • Use make with capacity for slices and maps.
  • Use sync.Pool only when profiling indicates allocation pressure.
  • Use the race detector in tests.
  • Profile before optimisinggo tool pprof is the conventional analysis tool.
  • Avoid finalisers — use defer for 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.