Memory Layout
Where this picks up
Section titled “Where this picks up”The Interfaces lesson already showed that an interface value is two words — an itab pointer and a data pointer. Every number in this lesson was measured with unsafe.Sizeof, unsafe.Alignof, and unsafe.Offsetof on darwin/arm64, a 64-bit platform — every size below assumes a 64-bit GOARCH (amd64, arm64); on a 32-bit platform (386, arm) every pointer-sized field below is 4 bytes instead of 8, and the totals shrink accordingly.
The real layout of every “handle” type
Section titled “The real layout of every “handle” type”The Types & Data module already described slices, maps, and pointers as small “handles” that copy cheaply while sharing what they point to. Here is exactly how many bytes each of those handles actually is:
package main
import ( "fmt" "unsafe")
type MyInterface interface{ M() }type impl struct{}
func (impl) M() {}
func main() { var s string var sl []int var m map[string]int var ch chan int var fn func() var p *int var i MyInterface = impl{} var a any
fmt.Println("string: Sizeof", unsafe.Sizeof(s), "Alignof", unsafe.Alignof(s)) fmt.Println("[]int: Sizeof", unsafe.Sizeof(sl), "Alignof", unsafe.Alignof(sl)) fmt.Println("map: Sizeof", unsafe.Sizeof(m), "Alignof", unsafe.Alignof(m)) fmt.Println("chan: Sizeof", unsafe.Sizeof(ch), "Alignof", unsafe.Alignof(ch)) fmt.Println("func: Sizeof", unsafe.Sizeof(fn), "Alignof", unsafe.Alignof(fn)) fmt.Println("*int: Sizeof", unsafe.Sizeof(p), "Alignof", unsafe.Alignof(p)) fmt.Println("iface(any): Sizeof", unsafe.Sizeof(a), "Alignof", unsafe.Alignof(a)) fmt.Println("iface(1mth): Sizeof", unsafe.Sizeof(i), "Alignof", unsafe.Alignof(i))} Real output on Go 1.27, darwin/arm64:
string: Sizeof 16 Alignof 8[]int: Sizeof 24 Alignof 8map: Sizeof 8 Alignof 8chan: Sizeof 8 Alignof 8func: Sizeof 8 Alignof 8*int: Sizeof 8 Alignof 8iface(any): Sizeof 16 Alignof 8iface(1mth): Sizeof 16 Alignof 8Reading what those numbers actually mean:
| Type | Bytes (64-bit) | The words |
|---|---|---|
string | 16 | a *byte (pointer to the underlying bytes) and an int length. No capacity — a string can never grow in place. |
[]T (slice) | 24 | a pointer, an int length, and an int capacity — the one extra word over a string, and the reason append sometimes reallocates and sometimes does not. |
map[K]V | 8 | a single pointer to a runtime hmap struct. Every copy of a map value (assignment, passing to a function) copies that one pointer — every copy shares the same underlying buckets, which is why writes through one copy are visible through another. |
chan T | 8 | a single pointer to a runtime hchan struct, for the same reason a map is one word: the value you hold is a handle to shared state, not the state itself. |
func(...) | 8 | a single pointer to a closure object (the function’s code plus any captured variables) — this is also why passing a func value around is cheap regardless of how much it closed over. |
*T (any pointer) | 8 | exactly one machine word, the address, whatever T is. |
any / any interface value | 16 | two words: an itab (or, for any specifically, a *_type) pointer, and a data pointer — the exact two-word layout the Interfaces lesson already introduced, confirmed here with a real measurement rather than a diagram. |
The pattern across all seven: every one of these types is a small, fixed-size handle, no matter how much data it refers to. A map[string][]byte with ten million entries is still 8 bytes to copy. This is also exactly why none of them come with a useful zero-value “empty container” unless the runtime specifically designed one in — a nil slice (len/cap both 0, pointer nil) is usable and empty, a nil map is readable but panics on write, because the handle alone carries no bucket storage to write into.
Struct padding and field reordering
Section titled “Struct padding and field reordering”The compiler must place every field of a struct at an offset that is a multiple of that field’s own alignment (an int64 needs offset ≡ 0 mod 8, an int32 needs offset ≡ 0 mod 4, and so on) — and it never reorders your fields to save space; it only inserts padding between them if declaration order demands it.
package main
import ( "fmt" "unsafe")
// Badly ordered: bool (1 byte) forces padding before the next 8-byte field.type Bad struct { A bool // 1 byte B int64 // 8 bytes -- needs 8-byte alignment C bool // 1 byte D int32 // 4 bytes -- needs 4-byte alignment}
// Same fields, ordered largest-to-smallest: no padding needed until the end.type Good struct { B int64 // 8 bytes D int32 // 4 bytes A bool // 1 byte C bool // 1 byte}
func main() { var bad Bad var good Good fmt.Println("Bad Sizeof:", unsafe.Sizeof(bad)) fmt.Println("Good Sizeof:", unsafe.Sizeof(good))
fmt.Println("Bad.A offset:", unsafe.Offsetof(bad.A)) fmt.Println("Bad.B offset:", unsafe.Offsetof(bad.B)) fmt.Println("Bad.C offset:", unsafe.Offsetof(bad.C)) fmt.Println("Bad.D offset:", unsafe.Offsetof(bad.D))
fmt.Println("Good.B offset:", unsafe.Offsetof(good.B)) fmt.Println("Good.D offset:", unsafe.Offsetof(good.D)) fmt.Println("Good.A offset:", unsafe.Offsetof(good.A)) fmt.Println("Good.C offset:", unsafe.Offsetof(good.C))} Real output on Go 1.27, darwin/arm64:
Bad Sizeof: 24Good Sizeof: 16Bad.A offset: 0Bad.B offset: 8Bad.C offset: 16Bad.D offset: 20Good.B offset: 0Good.D offset: 8Good.A offset: 12Good.C offset: 13Same four fields, same data, 33% smaller just from ordering: Bad.A sits at offset 0 (1 byte), then 7 bytes of padding before Bad.B can start at offset 8 (its alignment requires a multiple of 8); Bad.C at offset 16 is followed by 3 bytes of padding before Bad.D can start at offset 20 (a multiple of 4); and the struct’s own overall size gets rounded up to a multiple of its largest field’s alignment (8), landing on 24. Good orders fields largest-alignment-first, so nothing after B and D ever needs to wait for an aligned boundary — A and C, at 1 byte each with no alignment requirement, simply pack next to each other at the end. The rule of thumb this justifies: when several fields in a struct have no other constraint on their order (no reason one must come before another for readability), order them from largest alignment to smallest — this never makes a struct bigger and sometimes makes it meaningfully smaller, at zero cost to correctness.
Cache lines and false sharing
Section titled “Cache lines and false sharing”A CPU does not fetch memory one field at a time — it fetches a fixed-size cache line, 64 bytes on essentially every current x86-64 and arm64 processor, and keeps a per-core cached copy of it. When two different CPU cores are writing to two different variables that happen to sit inside the same 64-byte cache line, the CPU’s cache-coherency protocol has to invalidate and refetch that entire line on every write from either core — even though the two goroutines never touch each other’s data. This is false sharing: contention that looks, in the source, like it should not exist.
Because reproducing this requires a real, sustained benchmark (a Playground’s single run cannot show a timing difference reliably), this is a fenced snippet with real captured numbers, not a Playground — run it locally with go test -bench=. -benchmem.
// Unpadded: both counters likely share one 64-byte cache line.type UnpaddedCounters struct { A int64 B int64}
// Padded: pad B onto its own cache line so updates to A and B never// invalidate the same cache line in another core.type PaddedCounters struct { A int64 _ [56]byte // pad to 64 bytes total (int64 is 8 bytes) B int64 pad2 [56]byte}
// BenchmarkUnpadded and BenchmarkPadded each start two goroutines that// concurrently increment A and B in a tight loop, then wg.Wait().Real output, three repeated runs, Go 1.27, darwin/arm64 (Apple M3 Pro):
BenchmarkUnpadded-11 933144940 1.318 ns/op 0 B/op 0 allocs/opBenchmarkUnpadded-11 918987140 1.322 ns/op 0 B/op 0 allocs/opBenchmarkUnpadded-11 913888532 1.318 ns/op 0 B/op 0 allocs/opBenchmarkPadded-11 1000000000 0.2850 ns/op 0 B/op 0 allocs/opBenchmarkPadded-11 1000000000 0.2736 ns/op 0 B/op 0 allocs/opBenchmarkPadded-11 1000000000 0.2653 ns/op 0 B/op 0 allocs/opPadding B onto its own cache line made these two goroutines’ increments roughly 4.6x faster, with zero change to what either goroutine actually computes — A and B are still two completely independent counters, never read or written by more than one goroutine each. The entire cost difference is cache-coherency traffic caused purely by memory layout. This is a niche optimization — reach for it only after a profiler has pointed at exactly this pattern (independent counters or fields, hot, updated from different goroutines/cores) — but recognizing the shape in code (fields deliberately separated by a byte-array padding field, sometimes named _pad or similar) is worth being able to do on sight, since it otherwise looks like dead, wasted space.
unsafe.Pointer: recognizing the rules, not writing them
Section titled “unsafe.Pointer: recognizing the rules, not writing them”unsafe.Pointer is how Go’s type system gets deliberately bypassed, and the standard library’s own documentation (go doc unsafe Pointer) is explicit that “code not using these patterns is likely to be invalid today or to become invalid in the future” — this course does not teach you to write it, only to recognize the handful of shapes that are actually sanctioned so unfamiliar unsafe code does not read as automatically suspicious or automatically safe. Three patterns cover nearly everything you will see: (1) converting *T1 to unsafe.Pointer to *T2 to reinterpret one type’s bytes as another’s (the standard library’s own math.Float64bits does exactly this to read a float64’s bits as a uint64); (2) converting a Pointer to a uintptr purely to print or log an address, never converting that uintptr back — the documentation is blunt that a uintptr “is an integer, not a reference,” so the garbage collector will neither update it if the object moves nor keep the object alive because of it; (3) the three-step unsafe.Pointer(uintptr(p) + offset) idiom to step to a specific field or element and back to Pointer in the same expression, never storing the intermediate uintptr in a variable across a statement boundary (across which a GC-driven stack move — see the Scheduler lesson — could invalidate it). A go vet pass flags many violations of these patterns, but its own documentation warns that a clean pass “is not a guarantee that the code is valid.”
Mental Model
Section titled “Mental Model”Handle types (always this many bytes on 64-bit, no matter the data size):
string [ptr|len] 16 bytes[]T [ptr|len|cap] 24 bytesmap [ptr] 8 bytes -> runtime hmap{...}chan [ptr] 8 bytes -> runtime hchan{...}func [ptr] 8 bytes -> closure{code, captured vars}*T [ptr] 8 bytesany [itab/type ptr|data ptr] 16 bytes
Struct layout: fields placed in DECLARATION order, padding insertedto satisfy each field's alignment -- reordering fields, not the compiler,is how you remove that padding.Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Fixed-size handles (slice, map, chan, func, interface) | Cheap to copy and pass regardless of underlying data size | An extra pointer indirection to reach the real data |
| Largest-alignment-first struct field ordering | Smaller structs, more of them fit in a cache line, for free | A small readability cost if the “natural” field order (matching a spec or protocol) is not alignment-optimal |
| Cache-line padding to prevent false sharing | Can produce multi-x speedups on hot, independently-updated fields | Wastes real memory (63 bytes of padding per padded field here) and adds real complexity — a niche, profiler-justified tool |
unsafe.Pointer | Lets performance-critical code reinterpret memory without a copy | Bypasses every safety guarantee the rest of this course relies on; only a handful of patterns are sanctioned |
Common Misconceptions
Section titled “Common Misconceptions”- “A slice, map, and channel are roughly the same ‘reference type’ size.” A slice is 24 bytes (three words); a map and a channel are each a single 8-byte pointer. All three share the “handle to shared state” shape, but not the same size.
- “The compiler reorders struct fields to minimize size, like some other languages’ compilers do.” Go’s compiler never reorders your fields — declaration order is layout order. Any size reduction from field ordering is something you have to do yourself.
- “Padding is always wasted space to eliminate.” Padding for alignment is unavoidable and usually harmless; padding added deliberately to prevent false sharing is spending memory on purpose for a measured performance win — the two look identical in a struct definition but mean opposite things.
- “Converting a pointer to
uintptrand back later is fine as long as you don’t change the number.” The address alone tells the runtime nothing about whether the object it pointed to is still there, or hasn’t moved (see the Scheduler lesson’s stack-copying example) — only the same-expression round-trip pattern is sanctioned.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated Go rarely reasons about layout at all — structs get field order matching whatever order felt natural to write, and unsafe code, when it appears, is often copied from an unrelated example without checking which of the sanctioned patterns it actually matches. Check for these:
- Does a large, frequently-allocated struct interleave small fields (
bool,byte, small enums) between 8-byte fields (int64, pointers, slices, strings, interfaces) for no readability reason, when reordering would shrink it for free? - Is a
uintptrobtained from anunsafe.Pointerstored in a variable, struct field, or slice — anywhere it could outlive the single expression that produced it — rather than being consumed immediately? - Does a comment near cache-line-padding-shaped fields (an unexplained
_ [N]bytebetween two hot fields) exist, or is padding like that present with no explanation at all, leaving a reader to guess whether it is deliberate or leftover cruft? - Is
unsafe.Pointerused to convert between two struct types that do not actually share an equivalent memory layout — different field order, different field types, or different sizes — which pattern (1) explicitly requires?
🐛 Spot the bug
type Flags struct { Debug bool MaxConns int64 Verbose bool Timeout int32 ReadOnly bool}
func NewFlags() *Flags { return &Flags{MaxConns: 100, Timeout: 30}}Show the answer
Nothing here is incorrect Go — it compiles, runs, and behaves exactly as written. The bug is a wasted-bytes one: three bool fields (1 byte each) are interleaved with an int64 (8 bytes) and an int32 (4 bytes) in declaration order, which is also layout order. Every bool before an 8-byte-aligned field forces padding up to the next 8-byte boundary, the same shape as this lesson’s Bad struct. Reordering by descending alignment removes all of it with zero behavior change:
type Flags struct { MaxConns int64 // 8 bytes Timeout int32 // 4 bytes Debug bool // 1 byte Verbose bool // 1 byte ReadOnly bool // 1 byte}For one struct this saves a handful of bytes — not worth a code review comment on its own. It becomes worth flagging when the struct is allocated per-request, per-connection, or per-row at scale, where the wasted padding is now multiplied by however many live at once.
💡 Real-world example
Go’s own
time.Time(time/time.go, standard library) deliberately orders its three fields —wall uint64,ext int64,loc *Location— largest-alignment-first, and the type’s doc comment goes out of its way to warn thatTimevalues “should not be compared using==” partly because of exactly this kind of internal representation detail: the layout is designed for size and access-pattern efficiency, not for looking like the most natural declaration order a reader might reach for first.