Understanding Go Struct Alignment and Its Performance Implications
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Introduction
In the world of low-level programming and performance optimization, understanding how data is laid out in memory is paramount. For Go developers, this often leads to a deeper dive into one of its fundamental building blocks: the struct
. While seemingly straightforward, the way Go arranges struct fields in memory, a process known as memory alignment, can have significant implications for both application performance and memory footprint. Neglecting to consider alignment might lead to unexpected memory padding, increased CPU cache misses, and ultimately, a slower program. This article will demystify Go's struct memory alignment, explain its principles, and demonstrate how thoughtful struct design can lead to more efficient and performant Go applications.
Core Concepts of Memory Alignment
Before we dive into the specifics of Go, let's establish a foundational understanding of the core concepts related to memory alignment.
- Memory Address: Each byte in computer memory has a unique numerical address. When we refer to data being stored at a certain address, it means the starting byte of that data block.
- Word Size: The native unit of data that a CPU can process efficiently in a single operation. For 64-bit systems, the word size is typically 8 bytes; for 32-bit systems, it's 4 bytes. Accessing data that spans across multiple word boundaries can be less efficient.
- Alignment Requirement: For primitive data types (like
int
,float64
,bool
), there's an inherent alignment requirement. For example, on a 64-bit system:- A
byte
(1 byte) can be stored at any address. - A
short
(2 bytes) usually needs to start at an even address (divisible by 2). - An
int
(4 bytes) typically needs to start at an address divisible by 4. - A
long
ordouble
(8 bytes) usually needs to start at an address divisible by 8. These requirements ensure that the CPU can fetch the data in a single operation, aligned with its internal data bus.
- A
- Padding: When a struct's fields are not perfectly aligned according to their types' requirements and the CPU's architecture, the compiler inserts "padding" bytes between fields or at the end of the struct. These padding bytes are essentially wasted memory, inserted to ensure that subsequent fields or array elements are correctly aligned.
- Cache Lines: Modern CPUs use a technique called caching to speed up memory access. Data is fetched from main memory into smaller, faster CPU caches in chunks called cache lines (typically 64 bytes). When you access a piece of data, the entire cache line containing that data is brought into the cache. If your data is laid out efficiently, related data will reside within the same cache line, leading to fewer cache misses and faster access.
Go Struct Alignment In Depth
Go, like many other compiled languages, automatically handles memory alignment for structs. It follows a set of rules to achieve this:
- Field Alignment: Each field in a struct is aligned to its natural alignment requirement or the alignment of the struct, whichever is smaller.
- Struct Alignment: The alignment of a struct itself is equal to the largest alignment requirement of any of its fields.
- Struct Size: The total size of a struct will be a multiple of its alignment requirement. Padding bytes are added at the end of the struct if necessary to satisfy this rule.
Let's illustrate these rules with practical Go code examples. We'll use the unsafe
package's Sizeof
(size in bytes), Alignof
(alignment requirement), and Offsetof
(offset of a field within a struct) functions to inspect memory layout.
Consider the following struct definitions:
package main import ( "fmt" "unsafe" ) type S1 struct { A bool // 1 byte B int32 // 4 bytes C bool // 1 byte } type S2 struct { A bool // 1 byte C bool // 1 byte B int32 // 4 bytes } type S3 struct { A bool // 1 byte B int64 // 8 bytes C float64 // 8 bytes D int32 // 4 bytes E bool // 1 byte } type S4 struct { B int64 // 8 bytes C float64 // 8 bytes D int32 // 4 bytes A bool // 1 byte E bool // 1 byte } func main() { // S1 analysis fmt.Println("=== S1 (A bool, B int32, C bool) ===") fmt.Printf("Sizeof(S1): %d bytes\n", unsafe.Sizeof(S1{})) fmt.Printf("Alignof(S1): %d bytes\n", unsafe.Alignof(S1{})) fmt.Printf("Offsetof(S1.A): %d bytes, Sizeof(A): %d bytes, Alignof(A): %d bytes\n", unsafe.Offsetof(S1{}.A), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Printf("Offsetof(S1.B): %d bytes, Sizeof(B): %d bytes, Alignof(B): %d bytes\n", unsafe.Offsetof(S1{}.B), unsafe.Sizeof(int32(0)), unsafe.Alignof(int32(0))) fmt.Printf("Offsetof(S1.C): %d bytes, Sizeof(C): %d bytes, Alignof(C): %d bytes\n", unsafe.Offsetof(S1{}.C), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Println() // S2 analysis fmt.Println("=== S2 (A bool, C bool, B int32) ===") fmt.Printf("Sizeof(S2): %d bytes\n", unsafe.Sizeof(S2{})) fmt.Printf("Alignof(S2): %d bytes\n", unsafe.Alignof(S2{})) fmt.Printf("Offsetof(S2.A): %d bytes, Sizeof(A): %d bytes, Alignof(A): %d bytes\n", unsafe.Offsetof(S2{}.A), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Printf("Offsetof(S2.C): %d bytes, Sizeof(C): %d bytes, Alignof(C): %d bytes\n", unsafe.Offsetof(S2{}.C), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Printf("Offsetof(S2.B): %d bytes, Sizeof(B): %d bytes, Alignof(B): %d bytes\n", unsafe.Offsetof(S2{}.B), unsafe.Sizeof(int32(0)), unsafe.Alignof(int32(0))) fmt.Println() // S3 analysis fmt.Println("=== S3 (A bool, B int64, C float64, D int32, E bool) ===") fmt.Printf("Sizeof(S3): %d bytes\n", unsafe.Sizeof(S3{})) fmt.Printf("Alignof(S3): %d bytes\n", unsafe.Alignof(S3{})) fmt.Printf("Offsetof(S3.A): %d, Sizeof(A): %d, Alignof(A): %d\n", unsafe.Offsetof(S3{}.A), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Printf("Offsetof(S3.B): %d, Sizeof(B): %d, Alignof(B): %d\n", unsafe.Offsetof(S3{}.B), unsafe.Sizeof(int64(0)), unsafe.Alignof(int64(0))) fmt.Printf("Offsetof(S3.C): %d, Sizeof(C): %d, Alignof(C): %d\n", unsafe.Offsetof(S3{}.C), unsafe.Sizeof(float64(0)), unsafe.Alignof(float64(0))) fmt.Printf("Offsetof(S3.D): %d, Sizeof(D): %d, Alignof(D): %d\n", unsafe.Offsetof(S3{}.D), unsafe.Sizeof(int32(0)), unsafe.Alignof(int32(0))) fmt.Printf("Offsetof(S3.E): %d, Sizeof(E): %d, Alignof(E): %d\n", unsafe.Offsetof(S3{}.E), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Println() // S4 analysis fmt.Println("=== S4 (B int64, C float64, D int32, A bool, E bool) ===") fmt.Printf("Sizeof(S4): %d bytes\n", unsafe.Sizeof(S4{})) fmt.Printf("Alignof(S4): %d bytes\n", unsafe.Alignof(S4{})) fmt.Printf("Offsetof(S4.B): %d, Sizeof(B): %d, Alignof(B): %d\n", unsafe.Offsetof(S4{}.B), unsafe.Sizeof(int64(0)), unsafe.Alignof(int64(0))) fmt.Printf("Offsetof(S4.C): %d, Sizeof(C): %d, Alignof(C): %d\n", unsafe.Offsetof(S4{}.C), unsafe.Sizeof(float64(0)), unsafe.Alignof(float64(0))) fmt.Printf("Offsetof(S4.D): %d, Sizeof(D): %d, Alignof(D): %d\n", unsafe.Offsetof(S4{}.D), unsafe.Sizeof(int32(0)), unsafe.Alignof(int32(0))) fmt.Printf("Offsetof(S4.A): %d, Sizeof(A): %d, Alignof(A): %d\n", unsafe.Offsetof(S4{}.A), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Printf("Offsetof(S4.E): %d, Sizeof(E): %d, Alignof(E): %d\n", unsafe.Offsetof(S4{}.E), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Println() }
Let's analyze the output (on a 64-bit system where int32
is 4 bytes, int64
and float64
are 8 bytes, and bool
is 1 byte):
S1 Analysis (A bool
, B int32
, C bool
)
=== S1 (A bool, B int32, C bool) ===
Sizeof(S1): 12 bytes
Alignof(S1): 4 bytes
Offsetof(S1.A): 0 bytes, Sizeof(A): 1 bytes, Alignof(A): 1 bytes
Offsetof(S1.B): 4 bytes, Sizeof(B): 4 bytes, Alignof(B): 4 bytes
Offsetof(S1.C): 8 bytes, Sizeof(C): 1 bytes, Alignof(C): 1 bytes
A
(bool, 1 byte) starts at offset 0.- To align
B
(int32, 4 bytes) which requires 4-byte alignment, 3 padding bytes are inserted afterA
.B
effectively starts at offset 4. C
(bool, 1 byte) starts at offset 8 (immediately afterB
). Total occupied memory is 1 (A) + 3 (padding) + 4 (B) + 1 (C) = 9 bytes.- The largest alignment requirement among S1's fields is
int32
's 4 bytes. Therefore,Alignof(S1)
is 4 bytes. - The total size of the struct must be a multiple of its alignment (4). Since 9 is not, 3 more padding bytes are added at the end, bringing the total size to 12 bytes.
Memory Layout for S1:
[A][P][P][P][B][B][B][B][C][P][P][P]
0 1 2 3 4 5 6 7 8 9 10 11
(P = Padding)
S2 Analysis (A bool
, C bool
, B int32
)
=== S2 (A bool, C bool, B int32) ===
Sizeof(S2): 8 bytes
Alignof(S2): 4 bytes
Offsetof(S2.A): 0 bytes, Sizeof(A): 1 bytes, Alignof(A): 1 bytes
Offsetof(S2.C): 1 bytes, Sizeof(C): 1 bytes, Alignof(C): 1 bytes
Offsetof(S2.B): 4 bytes, Sizeof(B): 4 bytes, Alignof(B): 4 bytes
A
(bool, 1 byte) starts at offset 0.C
(bool, 1 byte) can immediately followA
at offset 1.- Now we have 2 bytes used. To align
B
(int32, 4 bytes) which requires 4-byte alignment, 2 padding bytes are inserted afterC
.B
effectively starts at offset 4. - Total occupied memory is 1 (A) + 1 (C) + 2 (padding) + 4 (B) = 8 bytes.
Alignof(S2)
is 4 bytes (due toint32
).- The total size of the struct (8 bytes) is already a multiple of 4, so no trailing padding is added.
Memory Layout for S2:
[A][C][P][P][B][B][B][B]
0 1 2 3 4 5 6 7
(P = Padding)
Notice how simply reordering fields reduced the struct size from 12 bytes to 8 bytes, a 33% memory saving!
S3 Analysis (A bool
, B int64
, C float64
, D int32
, E bool
)
=== S3 (A bool, B int64, C float64, D int32, E bool) ===
Sizeof(S3): 32 bytes
Alignof(S3): 8 bytes
Offsetof(S3.A): 0, Sizeof(A): 1, Alignof(A): 1
Offsetof(S3.B): 8, Sizeof(B): 8, Alignof(B): 8
Offsetof(S3.C): 16, Sizeof(C): 8, Alignof(C): 8
Offsetof(S3.D): 24, Sizeof(D): 4, Alignof(D): 4
Offsetof(S3.E): 28, Sizeof(E): 1, Alignof(E): 1
A
(bool, 1 byte) at offset 0.- To align
B
(int64, 8 bytes), 7 padding bytes are added.B
starts at offset 8. C
(float64, 8 bytes) starts at offset 16 (8 + 8).D
(int32, 4 bytes) starts at offset 24 (16 + 8).E
(bool, 1 byte) starts at offset 28 (24 + 4).- Total raw size: 1+7+8+8+4+1 = 29 bytes.
Alignof(S3)
is 8 bytes (int64
andfloat64
).- Trailing padding: 29 bytes need to be rounded up to the nearest multiple of 8, which is 32. So, 3 padding bytes are added at the end.
S4 Analysis (B int64
, C float64
, D int32
, A bool
, E bool
)
=== S4 (B int64, C float64, D int32, A bool, E bool) ===
Sizeof(S4): 24 bytes
Alignof(S4): 8 bytes
Offsetof(S4.B): 0, Sizeof(B): 8, Alignof(B): 8
Offsetof(S4.C): 8, Sizeof(C): 8, Alignof(C): 8
Offsetof(S4.D): 16, Sizeof(D): 4, Alignof(D): 4
Offsetof(S4.A): 20, Sizeof(A): 1, Alignof(A): 1
Offsetof(S4.E): 21, Sizeof(E): 1, Alignof(E): 1
B
(int64, 8 bytes) starts at offset 0.C
(float64, 8 bytes) starts at offset 8.D
(int32, 4 bytes) starts at offset 16.A
(bool, 1 byte) starts at offset 20.E
(bool, 1 byte) starts at offset 21.- Total raw size: 8+8+4+1+1 = 22 bytes.
Alignof(S4)
is 8 bytes.- Trailing padding: 22 bytes need to be rounded up to the nearest multiple of 8, which is 24. So, 2 padding bytes are added at the end.
Again, S4 achieved significant memory reduction compared to S3 (24 bytes versus 32 bytes) by simply reordering fields to group smaller types together.
Performance Implications
Memory alignment impacts performance in several ways:
- Memory Footprint: As shown above, unnecessary padding increases the total memory consumed by structs. This is particularly critical when you have large slices or arrays of structs. More memory translates to higher memory bandwidth usage, more pressure on the garbage collector, and potentially more swapping to disk if physical RAM is scarce.
- CPU Cache Efficiency: This is often the most significant performance factor. When a CPU fetches data, it loads entire cache lines from main memory into its L1/L2/L3 caches.
- Aligned Access: If your data is aligned, the CPU can usually fetch it in a single memory access within a cache line.
- Unaligned Access (for a single field): If a single field spans across a cache line boundary, the CPU might need to perform two memory accesses to fetch that single piece of data, significantly slowing down its retrieval. Go ensures fields are aligned to avoid this scenario.
- False Sharing: This is a more subtle but important issue in concurrent programming. If two different Goroutines frequently access different fields within the same cache line of a struct, even if those fields are completely unrelated, the CPU's cache coherence protocol will invalidate and re-synchronize that cache line repeatedly between cores. This leads to excessive cache traffic and degrades performance. By reordering fields to group frequently accessed or related fields and separating unrelated or concurrently accessed fields, you can minimize false sharing. For example, if Goroutine A often updates
FieldA
and Goroutine B often updatesFieldB
, andFieldA
andFieldB
happen to fall into the same cache line, false sharing will occur. If you can moveFieldB
to a different cache line (e.g., by adding padding or reordering), you can avoid this penalty.
Practical Guidelines for Struct Field Ordering
To optimize for memory and performance in Go, follow these guidelines:
- Order by Size (Largest to Smallest): As a general rule, declare fields in decreasing order of their size (e.g.,
int64
,float64
, thenint32
,int16
,bool
,byte
). This tends to pack smaller fields densely at the end, minimizing internal padding. - Group Related Fields: If certain fields are frequently accessed together, try to place them contiguously. This improves cache locality, increasing the chances they'll be in the same cache line when fetched.
- Consider Concurrency (False Sharing): For structs accessed concurrently, identify fields that are frequently modified by different Goroutines. If possible, separate these "hot" fields onto different cache lines by inserting padding bytes between them (e.g., using a small array or an explicitly padded struct as a field). This is a more advanced optimization but crucial for high-performance concurrent systems.
- Use
go vet
andprintfunsafestrs
: Whilego vet
doesn't directly warn about suboptimal struct packing, understandingunsafe
package output (like in our examples) helps. There are also community tools and linters (though not built-in togo vet
for this specific optimization) that can suggest optimal struct layouts.
Let's look at an example applying the "largest to smallest" rule:
type OptimizedStruct struct { BigInt int64 // 8 bytes BigFloat float64 // 8 bytes MediumInt int32 // 4 bytes SmallInt int16 // 2 bytes TinyByte byte // 1 byte TinyBool bool // 1 byte } // Total: 8 + 8 + 4 + 2 + 1 + 1 = 24 bytes (with potentially 0 padding if aligned to 8)
Compare this to a poorly ordered one:
type UnoptimizedStruct struct { TinyBool bool // 1 byte BigInt int64 // 8 bytes TinyByte byte // 1 byte MediumInt int32 // 4 bytes BigFloat float64 // 8 bytes SmallInt int16 // 2 bytes }
Running the unsafe
analysis on OptimizedStruct
will likely show it being more compact than UnoptimizedStruct
, especially on 64-bit systems. OptimizedStruct
would likely be 24 bytes total (8 bytes alignment requirement, 24 is a multiple of 8), whereas UnoptimizedStruct
would contain significant internal and trailing padding.
Conclusion
Understanding Go's struct memory alignment is not just an academic exercise; it's a practical skill for writing efficient Go programs. By deliberately ordering struct fields, Go developers can significantly reduce memory consumption and improve CPU cache utilization, leading to faster and more resource-friendly applications. While the Go compiler handles alignment for correctness, the developer is responsible for the optimal layout, which can have a surprisingly large impact on performance, especially when dealing with large collections of data or highly concurrent workloads. Thoughtful struct field ordering leads to more compact data structures and better cache performance.