Escape Analysis & Alignment
Escape Analysis & Memory Alignment
Memory optimization usually comes down to two questions: 1. Did it hit the heap when it didn’t need to? (Excessive allocation) 2. Is it wasting space? (Poor alignment)
Part 1: Escape Analysis
“Escape Analysis” is the compiler phase that decides Stack vs. Heap.
Asking the Compiler
You don’t need to guess. Ask the compiler what it decided:
go build -gcflags="-m" main.goOutput Interpretation: * can inline main: Good. * x does not escape: Great (Stack). * moved to heap: x: Bad (Heap).
Common Escape Triggers
- Returning Pointers:
func New() *T { return &T{} }-> Escapes. - Interfaces:
func Log(v interface{}). The concrete type inside the interface box often escapes. - Closures: Variables captured by a closure might escape if the closure outlives the function.
- Unknown Size:
make([]int, n)always escapes ifnis dynamic.
The “Mid-Stack” Trick
If you need a large buffer inside a hot loop, allocate it once outside the loop or reuse a sync.Pool. * Bad: for { b := make([]byte, 1024); process(b) } (Trash on heap every loop) * Better: b := make([]byte, 1024); for { process(b) } (One alloc) * Best (Stack): b := [1024]byte{}; for { process(b[:]) } (Stack alloc if small enough)
Part 2: Memory Alignment (Padding)
CPU reads memory in words (e.g., 64-bit / 8 bytes). If a field doesn’t align with a word boundary, the compiler adds Padding (wasted bytes).
The Struct Layout Game
Consider this struct:
type BadStruct struct {
Flag bool // 1 byte
Counter int64 // 8 bytes
Active bool // 1 byte
}Total Size: * bool (1) + padding (7) -> to align int64 * int64 (8) * bool (1) + padding (7) -> to align struct size to 8 * Total: 24 bytes
Reshuffled:
type GoodStruct struct {
Counter int64 // 8 bytes (0-7)
Flag bool // 1 byte (8)
Active bool // 1 byte (9)
// padding (6) -> to align struct size to multiple of 8 (16)
}Total: 16 bytes. 33% savings just by reordering fields.
Tools used in 2026
Do not optimize manually unless you are bored. Use tools.
fieldalignment:bash go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest fieldalignment ./...It will report structs that use too much memory and suggest a fix.betteralign: A more modern wrapper that can automatically apply changes (-apply).
When does this matter?
- Single struct: Who cares? 8 bytes is nothing.
- Slice of 100 million structs: 8 bytes * 100M = 800MB of RAM wasted. This triggers GC more often, costs money on cloud bills, and slows down cache.
Rule: Optimize alignment for “Data Types” (things you store in DB/Arrays). Ignore it for “Service Types” (singletons, handlers).
More examples
Example: measure padding with unsafe.Sizeof
Save as main.go and go run . (with go mod init example if needed).
package main
import (
"fmt"
"unsafe"
)
type Wide struct {
A bool
B int64
C bool
D int32
}
type Tight struct {
B int64
D int32
A bool
C bool
}
func main() {
fmt.Println("Wide:", unsafe.Sizeof(Wide{}))
fmt.Println("Tight:", unsafe.Sizeof(Tight{}))
fmt.Println("saved bytes:", int(unsafe.Sizeof(Wide{})-unsafe.Sizeof(Tight{})))
}Expected (64-bit; illustrative):
Wide: 24
Tight: 16
saved bytes: 8
Example: reuse buffer vs allocate every loop
Save as main.go and go run . (with go mod init example if needed).
package main
import "fmt"
func process(b []byte) int {
sum := 0
for _, x := range b {
sum += int(x)
}
return sum
}
func main() {
// Better: one allocation reused (or stack array as view).
var stack [8]byte
buf := stack[:]
total := 0
for i := 0; i < 3; i++ {
for j := range buf {
buf[j] = byte(i + j)
}
total += process(buf)
}
fmt.Println("reused total:", total)
// Contrasting pattern (educational): allocate each iteration.
total = 0
for i := 0; i < 3; i++ {
b := make([]byte, 8)
for j := range b {
b[j] = byte(i + j)
}
total += process(b)
}
fmt.Println("alloc-each total:", total)
}Expected:
reused total: 108
alloc-each total: 108
Runnable example
Save as main.go. Then:
go mod init example
go run .
# go build -gcflags="-m" .package main
import (
"fmt"
"unsafe"
)
// Bad order: bools separated by int64 force padding.
type BadStruct struct {
Flag bool
Counter int64
Active bool
}
// Better order: large fields first, bools packed together.
type GoodStruct struct {
Counter int64
Flag bool
Active bool
}
// Escape triggers: return pointer, interface boxing, open-ended make.
func newCounter() *int {
n := 1
return &n // escapes
}
func boxAndPrint(v any) {
// Interface conversion often forces heap allocation of the concrete value.
_ = fmt.Sprintf("%v", v)
}
func main() {
fmt.Println("sizeof BadStruct:", unsafe.Sizeof(BadStruct{}))
fmt.Println("sizeof GoodStruct:", unsafe.Sizeof(GoodStruct{}))
// Live use of escaped values so the compiler cannot drop them entirely.
c := newCounter()
fmt.Println("escaped counter:", *c)
x := 42
boxAndPrint(x)
// Unknown size → typically heap.
n := 32
buf := make([]byte, n)
buf[0] = 7
fmt.Println("dynamic buffer first byte:", buf[0])
// Stack-friendly pattern: fixed array reused as a slice view.
var stackBuf [32]byte
view := stackBuf[:]
view[0] = 9
fmt.Println("stack-ish view first byte:", view[0])
}Expected output: (sizes are for 64-bit Go; 32-bit differs)
sizeof BadStruct: 24
sizeof GoodStruct: 16
escaped counter: 1
dynamic buffer first byte: 7
stack-ish view first byte: 9
What to notice: Field order changes padding and total size without changing semantics. Returning &n and boxing into any are classic escape triggers; a fixed [N]byte reused as [:] is a common stack-friendly hot-path pattern.
Try next: Reorder another struct (byte, int64, byte, int32) and recompute sizes; run -gcflags="-m" and match each “moved to heap” line to a trigger above.