Memory Layout of Go Values¶
Why start here¶
Every performance and correctness question in Go eventually comes down
to "what bytes does this value occupy, and who else points at them?"
Whether a slice append corrupts another slice, whether converting to
any allocates, why a map never gives memory back, why a struct is 32
bytes instead of 16 — all of these are layout questions, answerable
from a mental picture of the runtime's data structures instead of
guessing.
Measurements on this page: Go 1.26.4, darwin/arm64 (Apple M1 Pro).
All sizes are for 64-bit platforms.
The built-in types are small headers¶
fmt.Println(unsafe.Sizeof(""), unsafe.Sizeof([]int{}), unsafe.Sizeof(map[int]int{}),
unsafe.Sizeof(any(nil)), unsafe.Sizeof(make(chan int)), unsafe.Sizeof(func() {}))
// 16 24 8 16 8 8
| Type | Size | What the value actually is |
|---|---|---|
string |
16 B | {ptr *byte, len int} — immutable bytes, shared freely |
[]T |
24 B | {ptr *T, len int, cap int} — a view into a backing array |
map[K]V |
8 B | a pointer to a runtime map object (Swiss table since Go 1.24) |
chan T |
8 B | a pointer to a runtime hchan (buffer, lock, wait queues) |
func |
8 B | a pointer to a closure object: code pointer + captured variables |
any / interface{...} |
16 B | {type-or-itab pointer, data pointer} |
*T |
8 B | an address |
struct{} / [0]T |
0 B | no storage at all (all zero-size values may share one address) |
Passing a string, slice, map, channel, or function to another function
copies only the header. That is why passing a []byte of 1 GB costs
the same as passing one of 1 byte — and why the callee can modify the
caller's elements.
Slices: aliasing and growth¶
A slice is a view. Two slices that share a backing array see each
other's writes, and append writes in place when there is spare
capacity:
a := make([]int, 3, 10)
b := append(a, 100) // fits in cap: writes a's backing array at index 3
c := append(a, 200) // same slot again
fmt.Println(b[3], c[3]) // 200 200 ← b was silently changed
This is the classic bug when a function appends to a slice it was given and returns it, while the caller keeps using the original. Defences:
- Return a new slice and never reuse the old variable (
s = append(s, x)). - Cap the view before handing it out:
s[lo:hi:hi](full slice expression) makescap == len, so the next append must copy. slices.Clonewhen ownership is unclear.
When capacity runs out, append allocates a bigger array and copies.
The growth sequence measured by appending one element at a time:
4 8 16 32 64 128 256 512 848 1280 1792 2560 3408 5120
Doubling up to 256 elements, then a smooth transition toward ~1.25x,
and every size is rounded up to an allocator size class, which is
why the numbers are not exact multiples. Appending N elements one at a
time does O(log N) allocations and copies about 2N–4N elements in
total; make([]T, 0, n) when n is known does one allocation and no
copies.
A related leak: re-slicing keeps the whole backing array alive.
small := huge[:10] retains all of huge for as long as small lives.
Copy the bytes you need (slices.Clone(huge[:10])) when the source is
large and short-lived — for example, a 64 KB read buffer from which you
keep a 20-byte key.
Strings and []byte¶
A string is immutable, so the runtime may share its bytes. Converting
between string and []byte normally copies, because one side could
otherwise mutate the other. The compiler removes the copy in specific
patterns where it can prove the bytes are not retained:
| Pattern | Allocates? |
|---|---|
m[string(b)] (map lookup) |
No — measured 0 allocs |
string(b) == "GET" and other comparisons |
No |
"prefix" + string(b) used only temporarily |
Often no (small results use a stack buffer) |
for i, c := range []byte(s) |
No |
s := string(b) that escapes |
Yes — 1 allocation (2 when then boxed into any) |
unsafe.String(&b[0], len(b)) and unsafe.Slice(unsafe.StringData(s), len(s))
convert without copying, and are only correct if nobody ever mutates
the bytes afterwards. Use them in measured hot paths, not by default
(see unsafe, cgo, and System Calls).
Struct layout, alignment, and padding¶
Each type has an alignment: a value must start at an address that is a
multiple of it. On 64-bit platforms int64, pointers, and float64
align to 8; int32 to 4; bool/byte to 1. A struct's alignment is
its largest field's, and its size is rounded up to a multiple of that.
Go never reorders fields — the order you write is the order in
memory.
type Bad struct { type Good struct {
a bool // off 0 b int64 // off 0
b int64 // off 8 d int32 // off 8
c bool // off 16 a bool // off 12
d int32 // off 20 c bool // off 13
e bool // off 24 e bool // off 14
} // size 32 } // size 16
Bad: [a.......][bbbbbbbb][c...dddd][e.......] 32 bytes, 13 of padding
Good: [bbbbbbbb][dddd ace.] 16 bytes, 1 of padding
Measured with unsafe.Sizeof / unsafe.Offsetof: Bad is 32 bytes,
Good is 16. For one value it does not matter; for a []Bad with 10
million elements it is 320 MB versus 160 MB, and twice as many cache
lines to scan. Rule of thumb: order fields from largest alignment to
smallest. The fieldalignment analyzer
(golang.org/x/tools/go/analysis/passes/fieldalignment) reports
structs that could shrink, and also flags when pointer fields could be
grouped first — the GC only scans a struct up to its last pointer, so
putting pointers first shortens scanning.
Two subtler rules:
- A zero-size field at the end of a struct gets padded
(
struct{ x int64; z struct{} }is 16 bytes, not 8), so that&s.znever points past the object into the next one. Put zero-size marker fields first. - 64-bit atomic operations need 8-byte alignment, which 32-bit
platforms (386, ARM) do not guarantee for plain
int64fields. Theatomic.Int64/atomic.Uint64types guarantee it; prefer them toatomic.AddInt64(&s.n, 1)on a raw field.
Interfaces: two words and a possible allocation¶
An interface value is two words:
- The first word identifies the dynamic type. For non-empty interfaces it points to an itab: a cached method table for this (interface, concrete type) pair, created on first use and reused.
- The second word must be a pointer. If the value is already a pointer
(
*os.File), it is stored directly. Otherwise the value is copied into a heap object and the interface points at the copy: boxing.
Measured with testing.AllocsPerRun, assigning to a global any:
| Assignment | Allocs |
|---|---|
sink = 7 (constant) |
0 — compiler emits a static, read-only copy |
sink = n where n is an int ≥ 256 |
1 |
sink = n % 200 (value 0–255) |
0 — runtime has a static table of small integers |
sink = string(bs) |
2 — one for the string bytes, one for boxing the header |
This is why fmt.Println(x) allocates for most x (its parameters
are ...any), why logging in a hot loop shows up in profiles, and why
generic code over a type parameter is often faster than the same code
over an interface: the interface version boxes, the generic version
usually does not.
The typed-nil trap¶
An interface is nil only if both words are nil:
func find() error {
var err *MyError = nil
return err // interface = {*MyError, nil}: NOT nil
}
if find() != nil { /* always taken */ }
Return a literal nil for the "no error" case; never return a typed
pointer variable through an interface.
Maps¶
Since Go 1.24 maps are Swiss tables: slots grouped in eights, each group with an 8-byte control word holding 7 bits of each slot's hash, so a lookup checks 8 candidates with a few word operations. A large map is split into multiple tables (at most 1024 slots each) behind a directory, so growth rehashes one table at a time instead of the whole map.
What matters day to day:
- Map entries are not addressable (
&m[k]does not compile) because growth moves them. Store pointers, or read-modify-write. - Iteration order is randomized on purpose. Never depend on it.
- Maps never shrink. Measured: a
map[int64]int64with 1 M entries uses 36 MB; after deleting every key,len(m) == 0and it still uses 36 MB. Only dropping the map itself (m = nil→ 0 MB after GC) or copying the survivors into a new map frees the memory. A cache implemented as a map that spikes once and then shrinks back logically keeps its peak memory forever. - The same 1 M
int64values in a slice take 7.6 MB. Maps cost 4–5x the raw data for small keys/values (slots for load-factor headroom, control bytes, key+value copies). When keys are dense integers, a slice indexed by key beats a map in both memory and speed. - Concurrent map writes (or a write concurrent with a read) are
detected on a best-effort basis and crash the process with
fatal error: concurrent map writes— this is not a recoverable panic.
Channels and functions¶
A channel is a pointer to an hchan: a circular buffer of cap
elements, a mutex, and two queues of waiting goroutines (senders and
receivers). Its internals and costs are in
Channels, Mutexes, and the Memory Model.
A function value is a pointer to a closure object whose first word is the code pointer, followed by captured variables. Closures capture variables by reference: if a captured variable outlives the function's frame (the closure is returned or stored), the variable moves to the heap — see Escape Analysis.
Common pitfall¶
Keeping a small sub-slice of a large buffer. A server reads each
request into a 64 KB buffer, parses out a 16-byte session ID with
id := buf[off : off+16], and stores id in a long-lived map. Each
stored ID keeps its entire 64 KB buffer reachable: 100,000 sessions
retain 6.4 GB instead of 1.6 MB. The heap profile shows the memory
attributed to the buffer allocation site, not the map, which makes
it confusing to diagnose. The fix is one line — id :=
string(buf[off:off+16]) or slices.Clone(...) — and the habit is: any
time a slice crosses from a short-lived buffer into long-lived state,
copy it.
Review questions¶
- Why does passing a 1 GB slice to a function cost the same as passing a 10-byte one, and what can the callee do to the caller's data?
- After
b := append(a, x), when doaandbshare memory? - Reorder
struct { a bool; b *T; c int32; d bool }to minimize size. What is the size before and after? - Why does
var x any = nallocate whenn == 1000but not whenn == 100? - A map grew to 10 M entries during a traffic spike and now holds 1,000. How much memory does it hold, and how do you get it back?