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)
graph LR subgraph s["s := []int{...} (24 B header)"] p["ptr"] --- l["len = 3"] --- c["cap = 8"] end p --> arr["backing array: [1][2][3][ ][ ][ ][ ][ ]"] subgraph s2["t := s[1:2] (another header)"] p2["ptr"] --- l2["len = 1"] --- c2["cap = 7"] end p2 --> arr

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) makes cap == len, so the next append must copy.
  • slices.Clone when 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.z never 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 int64 fields. The atomic.Int64 / atomic.Uint64 types guarantee it; prefer them to atomic.AddInt64(&s.n, 1) on a raw field.

Interfaces: two words and a possible allocation

An interface value is two words:

graph LR subgraph eface["any (eface)"] t1["_type *"] --- d1["data *"] end subgraph iface["io.Reader (iface)"] t2["itab *"] --- d2["data *"] end t2 --> itab["itab: interface type, concrete type,<br/>method table [Read → (*File).Read]"] d1 --> v1["the value (or a copy of it)"] d2 --> v2["the value (or a copy of it)"]
  • 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]int64 with 1 M entries uses 36 MB; after deleting every key, len(m) == 0 and 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 int64 values 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

  1. 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?
  2. After b := append(a, x), when do a and b share memory?
  3. Reorder struct { a bool; b *T; c int32; d bool } to minimize size. What is the size before and after?
  4. Why does var x any = n allocate when n == 1000 but not when n == 100?
  5. 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?