When the compiler packs more than you see
You are writing a binary protocol where every byte matters. You define a struct with three integers and expect it to occupy 12 bytes. You serialize it to the network and the receiver complains about a malformed packet. You check the types, verify the endianness, and still can't find the missing bytes. The wire format shows 16 bytes. Four bytes of invisible data are hiding in your struct.
The compiler is adding padding. It rearranges memory to satisfy alignment rules that keep the CPU happy. You can't see the padding in the source code, but it exists in memory. You need to inspect the layout directly.
The three layout inspectors
Go provides three functions in the unsafe package to reveal memory layout. unsafe.Sizeof returns the size of a value in bytes. unsafe.Alignof returns the alignment requirement. unsafe.Offsetof returns the byte offset of a field within a struct.
Think of the compiler as a warehouse packer. The CPU is a forklift that can only pick up items that sit on specific grid lines. If a heavy box needs to start on a multiple-of-8 line, the packer inserts bubble wrap to push it there. Sizeof tells you the total volume of the box. Alignof tells you the grid spacing the forklift requires. Offsetof tells you exactly where a specific item sits inside the box.
These functions are compile-time constants. The compiler calculates the result when you build the program. You don't need a running instance to check the size. The expression inside the function is never evaluated at runtime.
package main
import (
"fmt"
"unsafe"
)
// Point holds two integers.
type Point struct {
X int
Y int
}
func main() {
// Sizeof returns the size in bytes. The compiler substitutes the constant here.
size := unsafe.Sizeof(Point{})
fmt.Println("Size:", size)
// Alignof returns the alignment requirement.
align := unsafe.Alignof(Point{})
fmt.Println("Align:", align)
// Offsetof returns the byte offset of a field within the struct.
offsetY := unsafe.Offsetof(Point{}.Y)
fmt.Println("Offset Y:", offsetY)
}
The compiler packs structs for speed, not for your intuition.
Compile-time evaluation and constant expressions
The most important property of these functions is that they run at compile time. The compiler replaces the call with a constant number. This means you can use the result in places that require constants, like array lengths or type switches.
You can write var buf [unsafe.Sizeof(Point{})]byte and the compiler creates an array of the exact size. The expression inside Sizeof is never executed. If you pass a function call, the function never runs.
package main
import (
"fmt"
"unsafe"
)
// heavyWork simulates an expensive operation.
// This function is never called because Sizeof does not evaluate its argument.
func heavyWork() int {
fmt.Println("This never prints")
return 42
}
func main() {
// The compiler looks at the return type of heavyWork, which is int.
// It substitutes the size of int. heavyWork is not executed.
size := unsafe.Sizeof(heavyWork())
fmt.Println("Size of int:", size)
}
This behavior allows you to inspect types without creating values. You can check the size of a struct even if you can't construct an instance. The compiler only cares about the type of the expression. Note that unsafe.Sizeof returns a uintptr, not a constant type. You can use it in array bounds, but you cannot assign it to a const. Write var x = unsafe.Sizeof(T) for a variable, or use it directly in a composite literal.
Alignment, padding, and wasted space
Alignment requirements come from the hardware. On most modern systems, an int64 must start at an address divisible by 8. A float64 also needs 8-byte alignment. A bool or byte can start anywhere.
When you mix types in a struct, the compiler inserts padding bytes to satisfy alignment. Padding increases the size of the struct. It also affects the offset of subsequent fields. The total size of the struct is rounded up to a multiple of the alignment requirement.
Consider a struct with two booleans and a 64-bit integer. If you place the booleans first, the compiler inserts padding after the first boolean to align the integer. It also inserts padding after the integer to align the second boolean. This wastes space.
package main
import (
"fmt"
"unsafe"
)
// WastefulLayout puts small fields around a large field.
// The compiler inserts 7 bytes of padding after A to align B.
// It inserts 7 bytes of padding after B to align C.
type WastefulLayout struct {
A bool
B int64
C bool
}
// CompactLayout groups small fields together.
// B takes 8 bytes. A and C follow immediately.
// The total size rounds up to 16 bytes.
type CompactLayout struct {
B int64
A bool
C bool
}
func main() {
wasteSize := unsafe.Sizeof(WastefulLayout{})
compactSize := unsafe.Sizeof(CompactLayout{})
fmt.Println("Wasteful size:", wasteSize)
fmt.Println("Compact size:", compactSize)
// Offsetof reveals the padding.
// In WastefulLayout, B starts at 8. C starts at 16.
wasteBOffset := unsafe.Offsetof(WastefulLayout{}.B)
wasteCOffset := unsafe.Offsetof(WastefulLayout{}.C)
fmt.Println("Wasteful B offset:", wasteBOffset)
fmt.Println("Wasteful C offset:", wasteCOffset)
}
Reorder fields by size. Small fields at the end save memory.
Slices, arrays, and the header trap
unsafe.Sizeof measures the value you pass. For composite types, this distinction matters. A slice is a descriptor, not the data. The slice value contains a pointer to the backing array, a length, and a capacity. The size of a slice is the size of this header.
On a 64-bit system, a slice header is 24 bytes. unsafe.Sizeof([]int{}) returns 24, regardless of how many elements the slice holds. The backing array lives elsewhere in memory.
An array is the data. unsafe.Sizeof([100]int{}) returns 800 on a 64-bit system where int is 64 bits. The array value contains all the elements.
package main
import (
"fmt"
"unsafe"
)
func main() {
// Slice size is the header size, not the data size.
sliceSize := unsafe.Sizeof([]int{})
fmt.Println("Slice size:", sliceSize)
// Array size includes all elements.
arraySize := unsafe.Sizeof([10]int{})
fmt.Println("Array size:", arraySize)
// Map size is the header size. The map data is on the heap.
mapSize := unsafe.Sizeof(map[string]int{})
fmt.Println("Map size:", mapSize)
}
Slices are headers. Arrays are data. Sizeof tells the truth.
Testing layout invariants
It is common to add tests that verify struct sizes in performance-critical code. If you change the struct, the test fails. This catches accidental layout changes that could break binary protocols or FFI bindings. Write a test that checks unsafe.Sizeof(MyStruct{}) == expectedSize. This is a defensive practice that documents your assumptions about memory layout.
Pitfalls and compiler errors
unsafe.Offsetof has strict syntax rules. It requires a field selector expression. You cannot pass a variable. The compiler rejects unsafe.Offsetof(x) with an error like unsafe.Offsetof requires field expression. You must write unsafe.Offsetof(x.Field).
You also cannot use these functions on types that don't have a fixed size. Channels and functions have implementation-defined layouts. The compiler stops you with an error if you try to measure them. unsafe.Sizeof(ch) fails because the channel type is not allowed as an operand.
If you try to use Offsetof on a non-struct type, the compiler complains. unsafe.Offsetof only works on struct fields. You can't ask for the offset of an element in an array using Offsetof.
The unsafe package bypasses type safety. The community treats it with caution. If you use unsafe to fix a design problem, the design is likely wrong. Use unsafe only when you need to interface with C, implement a zero-allocation pool, or debug memory layout. Linters like staticcheck warn about unsafe usage. Reviewers will ask for a strong justification.
unsafe breaks the rules. Use it to fix the rules, not to break them.
When to use layout inspection
Use unsafe.Sizeof when you need to allocate a buffer for binary serialization or verify struct packing. Use unsafe.Alignof when you are writing a custom allocator and need to respect alignment constraints. Use unsafe.Offsetof when you are calculating field positions for FFI or debugging padding waste. Use the encoding/binary package when you need portable serialization: manual layout inspection is fragile across architectures. Use reflect when you need to inspect types at runtime: unsafe functions are compile-time only.
Trust the compiler for layout. Use unsafe to verify, not to guess.