Understanding Memory in Go

Updated

July 30, 2026

Overview

Go manages memory automatically through its runtime, but understanding stack vs heap allocation helps write more efficient code.

Stack vs Heap

Stack

  • Fast allocation/deallocation
  • Function-local variables
  • Fixed size per goroutine (~2KB initial)
  • Automatically cleaned up when function returns

Heap

  • Dynamic allocation
  • Survives function scope
  • Managed by garbage collector
  • More expensive than stack

Escape Analysis

Go’s compiler decides where to allocate based on escape analysis:

func stackAlloc() int {
    x := 42      // Stays on stack
    return x     // Value copied
}

func heapAlloc() *int {
    x := 42      // Escapes to heap
    return &x    // Pointer returned
}

Check Escape Analysis

go build -gcflags="-m" main.go
# Shows: "moved to heap" for escaped variables

What Causes Escape

// 1. Returning pointer to local
func escape1() *int {
    n := 1
    return &n  // Escapes
}

// 2. Storing in interface
func escape2() {
    n := 1
    fmt.Println(n)  // Escapes (interface{} argument)
}

// 3. Closure capturing variable
func escape3() func() int {
    n := 1
    return func() int { return n }  // Escapes
}

// 4. Size too large for stack
func escape4() {
    _ = make([]byte, 10<<20)  // 10MB, escapes
}

Memory Layout

Value Types

type Point struct { X, Y int }
p := Point{1, 2}  // 16 bytes inline

Reference Types

s := make([]int, 10)  // Slice header on stack, data on heap
m := make(map[string]int)  // Map structure on heap

Best Practices

Reduce Allocations

// Bad: allocates each iteration
for i := 0; i < n; i++ {
    data := make([]byte, 1024)
    process(data)
}

// Good: reuse allocation
data := make([]byte, 1024)
for i := 0; i < n; i++ {
    process(data)
}

Preallocate Slices

// Bad: multiple reallocations
var result []int
for _, v := range data {
    result = append(result, v)
}

// Good: preallocate
result := make([]int, 0, len(data))
for _, v := range data {
    result = append(result, v)
}

Use Value Semantics When Possible

// May allocate
func process(p *Point) { }

// Stays on stack
func process(p Point) { }

Summary

Location Characteristics
Stack Fast, automatic, limited size
Heap Dynamic, GC-managed, survives scope
Causes Escape Example
Return pointer return &x
Interface fmt.Println(x)
Closure func() { use(x) }
Large size make([]byte, 1MB)

More examples

Example: value stays local

Save as main.go and go run . (with go mod init example if needed).

package main

import "fmt"

func double(x int) int {
    return x * 2
}

func main() {
    n := 21
    fmt.Println("double:", double(n))
    fmt.Println("original:", n)
}

Expected:

double: 42
original: 21

Example: interface argument often forces heap

Save as main.go and go run . (with go mod init example if needed).

package main

import "fmt"

func show(v any) {
    fmt.Printf("value=%v type=%T\n", v, v)
}

func main() {
    x := 99
    show(x) // boxing into any is a common escape trigger
    show("ok")
}

Expected:

value=99 type=int
value=ok type=string

Runnable example

Save as main.go. Then:

go mod init example
go run .
# Optional: see escape decisions
# go build -gcflags="-m" .
package main

import "fmt"

type Point struct{ X, Y int }

// Returns a value: caller gets a copy; x can stay on the stack.
func stackAlloc() int {
    x := 42
    return x
}

// Returns a pointer: x must live past the call, so it escapes to the heap.
func heapAlloc() *int {
    x := 42
    return &x
}

// Reuse one buffer instead of allocating per iteration.
func processReuse(n int) int {
    buf := make([]byte, 64)
    sum := 0
    for i := 0; i < n; i++ {
        buf[0] = byte(i)
        sum += int(buf[0])
    }
    return sum
}

func main() {
    fmt.Println("stackAlloc:", stackAlloc())

    p := heapAlloc()
    fmt.Println("heapAlloc value:", *p)
    // Do not print the address itself — it changes every run.

    // Slice header can be on the stack; backing array is typically on the heap.
    pts := make([]Point, 0, 3)
    pts = append(pts, Point{1, 2}, Point{3, 4})
    fmt.Printf("points len=%d cap=%d first=%v\n", len(pts), cap(pts), pts[0])

    fmt.Println("processReuse:", processReuse(5))
}

Expected output:

stackAlloc: 42
heapAlloc value: 42
points len=2 cap=3 first={1 2}
processReuse: 10

What to notice: Returning *int forces heap allocation; reusing one slice buffer avoids per-loop allocs. Run with -gcflags="-m" to confirm which locals the compiler marks as escaping.

Try next: Change heapAlloc to return int by value and re-run escape analysis; preallocate pts with make([]Point, 0, 100) and watch capacity stay put under many appends.