You're staring at a heap profile
Your service allocates 500MB of memory every minute. You trace the allocations back to a simple struct definition. The struct holds an ID, a timestamp, and a status flag. It should be tiny. The profiler says otherwise. The memory isn't coming from the data itself. It's coming from how Go lays out that data in RAM.
Understanding memory layout stops you from guessing why allocations spike. It explains why copying a slice shares data, why maps panic when uninitialized, and why interfaces carry hidden overhead. Go types fall into two categories: values that live inline and headers that point to heap data. Structs can be either. Slices, maps, and interfaces always allocate their payload on the heap.
The mental model
Go stores data in memory to balance speed and safety. The compiler packs struct fields side by side so the CPU can fetch them in one cache line. Slices, maps, and interfaces use a header structure. The header is small and fits in a register or on the stack. The header points to the actual data, which lives on the heap.
This split matters when you copy values. Copying a struct copies all its fields. Copying a slice copies only the header. The underlying array stays shared. Copying a map copies the pointer. Both variables point to the same hash table. Copying an interface copies the type and data pointers. The concrete value remains shared.
Minimal declarations
Here's the code that triggers the layout rules.
package main
import "fmt"
// Config holds settings for a service.
// The compiler packs fields tightly to save space.
type Config struct {
ID int
Name string
}
func main() {
// Struct lives inline. Fields sit next to each other in memory.
c := Config{ID: 1, Name: "alpha"}
// Slice header is small. The actual data lives on the heap.
ids := []int{10, 20, 30}
// Map is a pointer to a hash table structure on the heap.
scores := map[string]int{"alpha": 100}
// Interface holds a type descriptor and a pointer to the data.
var result interface{} = "success"
fmt.Println(c, ids, scores, result)
}
Structs: contiguous and padded
A struct is a block of memory containing its fields in declaration order. The compiler inserts padding between fields to satisfy alignment requirements. Modern CPUs read memory in chunks aligned to 4 or 8 bytes. If a field starts at an unaligned address, the CPU takes a penalty. The compiler adds invisible bytes to keep everything aligned.
Field order changes the size. A struct with a bool, an int64, and another bool wastes space. The compiler pads after the first bool to align the int64, then pads again after the int64 to align the second bool. Reordering fields to group large types together eliminates padding.
The receiver name is usually one or two letters matching the type. Write (c *Config) Reset() not (this *Config) Reset(). The community expects short names. Long receiver names clutter the code and signal that you're porting habits from another language.
Structs are contiguous. Padding is real. Reorder fields to save bytes.
Slices: headers and arrays
A slice is a descriptor containing three words: a pointer to the backing array, the length, and the capacity. The length is the number of elements you can access. The capacity is the number of elements allocated in the array. The slice header is tiny. It fits in a register.
When you append to a slice, Go checks if the capacity is sufficient. If there is room, it writes the new element and increments the length. If the capacity is full, Go allocates a new, larger array, copies the old data, and updates the header. The old array becomes garbage.
Copying a slice copies the header. Both slices point to the same array. Modifying an element through one slice modifies it for the other. This aliasing is a common source of bugs. If you need an independent copy, use append to a new slice or copy.
Don't pass a *string. Strings are already cheap to pass by value. A string header is just a pointer and a length, identical to a slice header but immutable. Passing a pointer to a string adds indirection without saving memory.
Slices are headers. Copy the header, share the array.
Maps: pointers to hash tables
A map variable holds a pointer to a runtime structure called hmap. The hmap contains metadata and pointers to buckets. Each bucket stores a fixed number of key-value pairs. When a bucket fills up, the map grows by allocating overflow buckets.
Map keys must be comparable. You can use integers, strings, structs with comparable fields, and pointers. You cannot use slices, maps, or functions as keys because their equality is not defined. The compiler rejects a map with an invalid key type with invalid map key type.
Map iteration order is random. The runtime shuffles the bucket order on every range loop. This prevents programs from relying on stable ordering. If you need sorted keys, collect the keys and sort them explicitly.
Assignment to a nil map panics at runtime with assignment to entry in nil map. Always initialize a map with make before writing to it. Reading from a nil map returns the zero value and does not panic.
Maps are pointers. Initialize them or get a panic.
Interfaces: type and data pairs
An interface value is a pair: a type descriptor and a data pointer. The type descriptor contains the concrete type's metadata. The data pointer points to the value. If the value is small, the runtime may store it directly in the interface value instead of allocating on the heap.
The nil trap catches many developers. An interface is nil only when both the type and data are nil. A typed nil is not nil. Assigning (*int)(nil) to an interface{} creates an interface with a type of *int and a nil data pointer. Comparing this interface to nil returns false. The type is present, so the interface is not empty.
Type assertions check the runtime type. If the types match, the assertion returns the value. If they don't, the assertion panics unless you use the comma-ok idiom. The comma-ok form returns the value and a boolean indicating success. Use the comma-ok form in production code to avoid panics on unexpected types.
Interfaces are pairs. A nil value with a type is not nil.
Realistic example: struct layout optimization
Here's how field ordering changes the footprint of a struct.
package main
import "fmt"
// BadLayout wastes memory due to padding between large and small fields.
// The compiler inserts gaps to keep fields aligned to their natural boundaries.
type BadLayout struct {
A bool // 1 byte
B int64 // 8 bytes. Compiler adds 7 bytes of padding after A.
C bool // 1 byte. Compiler adds 7 bytes of padding after C.
}
// GoodLayout groups fields by size to eliminate padding.
// Large fields come first, then small fields pack together.
type GoodLayout struct {
B int64 // 8 bytes
A bool // 1 byte
C bool // 1 byte. Total size drops from 24 to 16 bytes.
}
// PrintSize demonstrates how field order changes memory usage.
// This is useful for optimizing hot paths where millions of structs are allocated.
func PrintSize() {
fmt.Printf("BadLayout: %d bytes\n", 24)
fmt.Printf("GoodLayout: %d bytes\n", 16)
}
func main() {
PrintSize()
}
Realistic example: slice growth
Here's how slices grow when you append beyond capacity.
package main
import "fmt"
// GrowSlice demonstrates how the runtime allocates a new backing array.
// When capacity is exceeded, Go doubles the slice size for small slices.
func GrowSlice() {
// Start with capacity 2.
s := make([]int, 2, 2)
// Append triggers reallocation. The old array becomes garbage.
s = append(s, 3)
// Capacity jumps to 4. The slice header now points to new memory.
fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
}
func main() {
GrowSlice()
}
Pitfalls and errors
Accessing a slice index equal to its length panics at runtime with slice bounds out of range. The valid indices are 0 through len(s)-1. Use len to guard access or use append to grow the slice safely.
Comparing maps for equality with == is forbidden. The compiler rejects this with invalid operation: map comparison. Maps are reference types. Use a loop to compare keys and values manually.
Goroutine leaks happen when a goroutine waits on a channel that never gets closed. Always have a cancellation path. Use context.Context to signal shutdown. The context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines.
The worst goroutine bug is the one that never logs. Add a defer to log when a goroutine exits, or use a sync.WaitGroup to track active goroutines.
Decision matrix
Use a struct when you have a fixed set of related values and need cache-friendly layout. Use a slice when you need a dynamic sequence of items with fast indexing and appending. Use a map when you need fast lookups by key and don't care about iteration order. Use an interface when you want to decouple the caller from the concrete implementation. Use a plain value when the type is small and you want to avoid pointer indirection.
Trust the compiler on alignment. Optimize layout only when the profiler screams.