Struct Layout and Padding in Go for Smaller Memory Use
Reorder Go struct fields to cut padding: a 40 byte struct became 24, 160 MB became 96 MB for 4 million values, and fieldalignment finds the cases

A Go struct is laid out in memory in the order its fields are declared, and each field must start at an address that is a multiple of its alignment. A bool can sit at any address, an int32 at multiples of 4, an int64 and a float64 at multiples of 8. The compiler inserts padding bytes to satisfy this, and the order you wrote the fields in decides how much padding. For one struct it is a few bytes. For a slice of ten million structs, a map with a million values or a cache that holds records in memory, it is a large share of the footprint, and it is free to remove. This article prints the offsets of two orderings of the same fields, measures the memory and speed difference on 4 million values, and shows the analyzer that finds the cases for you.
Tested with Go 1.25.6, golang.org/x/tools v0.37.0 (fieldalignment), Apple M1 Max (10 cores, 32 GB), macOS arm64
It is part of the memory side of the Go performance guide, next to heap profiles (which show how much memory a type uses) and false sharing, where padding is added on purpose. Related: slices holding arrays and reducing allocations.
The rules
- Each type has a size and an alignment.
boolandbyteare 1 and 1;int16is 2 and 2;int32andfloat32are 4 and 4;int64,float64, pointers andinton 64 bit systems are 8 and 8; a string is 16 bytes (a pointer and a length) with alignment 8; a slice is 24 bytes. - A field's offset is rounded up to its alignment.
- A struct's alignment is the largest of its fields' alignments, and its size is rounded up to a multiple of that alignment, so that elements of an array are all aligned.
- Padding is the unused bytes between fields and at the end.
The unsafe package exposes the numbers: unsafe.Sizeof, unsafe.Alignof and unsafe.Offsetof. The lab prints them.
The lab
Two structs with the same six fields. Careless lists them in the order somebody thought of them; Ordered puts the largest alignment first.
package main
import (
"fmt"
"testing"
"unsafe"
)
// Fields in the order they were thought of.
type Careless struct {
Active bool
ID int64
Flag bool
Count int32
Small bool
Score float64
}
// The same fields, largest alignment first.
type Ordered struct {
ID int64
Score float64
Count int32
Active bool
Flag bool
Small bool
}
type Empty struct{}
type WithTrailingEmpty struct {
A int64
E Empty
}
type Mixed struct {
A byte
B int16
C byte
D int32
E byte
}
var csink int64
func describe(name string, size, align uintptr, offsets map[string]uintptr, order []string) {
fmt.Printf("%-18s size %2d align %d offsets:", name, size, align)
for _, f := range order {
fmt.Printf(" %s=%d", f, offsets[f])
}
fmt.Println()
}
func main() {
var c Careless
var o Ordered
var m Mixed
describe("Careless", unsafe.Sizeof(c), unsafe.Alignof(c), map[string]uintptr{"Active": unsafe.Offsetof(c.Active), "ID": unsafe.Offsetof(c.ID), "Flag": unsafe.Offsetof(c.Flag), "Count": unsafe.Offsetof(c.Count), "Small": unsafe.Offsetof(c.Small), "Score": unsafe.Offsetof(c.Score)}, []string{"Active", "ID", "Flag", "Count", "Small", "Score"})
describe("Ordered", unsafe.Sizeof(o), unsafe.Alignof(o), map[string]uintptr{"ID": unsafe.Offsetof(o.ID), "Score": unsafe.Offsetof(o.Score), "Count": unsafe.Offsetof(o.Count), "Active": unsafe.Offsetof(o.Active), "Flag": unsafe.Offsetof(o.Flag), "Small": unsafe.Offsetof(o.Small)}, []string{"ID", "Score", "Count", "Active", "Flag", "Small"})
describe("Mixed", unsafe.Sizeof(m), unsafe.Alignof(m), map[string]uintptr{"A": unsafe.Offsetof(m.A), "B": unsafe.Offsetof(m.B), "C": unsafe.Offsetof(m.C), "D": unsafe.Offsetof(m.D), "E": unsafe.Offsetof(m.E)}, []string{"A", "B", "C", "D", "E"})
fmt.Println("Empty struct size:", unsafe.Sizeof(Empty{}), "| struct{int64; Empty} size:", unsafe.Sizeof(WithTrailingEmpty{}), "(a trailing zero size field is padded)")
fmt.Println()
const n = 4_000_000
cs := make([]Careless, n)
os := make([]Ordered, n)
for i := range cs {
cs[i] = Careless{ID: int64(i), Count: int32(i), Score: 1}
os[i] = Ordered{ID: int64(i), Count: int32(i), Score: 1}
}
fmt.Printf("4 million Careless: %d MB, 4 million Ordered: %d MB\n\n", uintptr(n)*unsafe.Sizeof(c)/1e6, uintptr(n)*unsafe.Sizeof(o)/1e6)
bench("scan 4M Careless, sum ID", func(b *testing.B) {
for b.Loop() {
var s int64
for i := range cs {
s += cs[i].ID
}
csink = s
}
})
bench("scan 4M Ordered, sum ID", func(b *testing.B) {
for b.Loop() {
var s int64
for i := range os {
s += os[i].ID
}
csink = s
}
})
bench("allocate 1M Careless", func(b *testing.B) {
for b.Loop() {
x := make([]Careless, 1_000_000)
csink += int64(len(x))
}
})
bench("allocate 1M Ordered", func(b *testing.B) {
for b.Loop() {
x := make([]Ordered, 1_000_000)
csink += int64(len(x))
}
})
}Careless size 40 align 8 offsets: Active=0 ID=8 Flag=16 Count=20 Small=24 Score=32
Ordered size 24 align 8 offsets: ID=0 Score=8 Count=16 Active=20 Flag=21 Small=22
Mixed size 16 align 4 offsets: A=0 B=2 C=4 D=8 E=12
Empty struct size: 0 | struct{int64; Empty} size: 16 (a trailing zero size field is padded)
4 million Careless: 160 MB, 4 million Ordered: 96 MB
scan 4M Careless, sum ID 3117517.0 ns/op 0 B/op 0 allocs/op
scan 4M Ordered, sum ID 3019182.8 ns/op 0 B/op 0 allocs/op
allocate 1M Careless 486344.3 ns/op 40001544 B/op 1 allocs/op
allocate 1M Ordered 331919.4 ns/op 24002568 B/op 1 allocs/oppackage main
import (
"flag"
"fmt"
"sort"
"testing"
)
func init() {
testing.Init()
flag.Set("test.benchtime", "200ms")
}
// bench runs f five times and prints the median, which damps the noise of a laptop.
func bench(name string, f func(b *testing.B)) {
const runs = 5
ns := make([]float64, 0, runs)
bs := make([]int64, 0, runs)
as := make([]int64, 0, runs)
for i := 0; i < runs; i++ {
r := testing.Benchmark(f)
ns = append(ns, float64(r.T.Nanoseconds())/float64(r.N))
bs = append(bs, r.AllocedBytesPerOp())
as = append(as, r.AllocsPerOp())
}
sort.Float64s(ns)
sort.Slice(bs, func(i, j int) bool { return bs[i] < bs[j] })
sort.Slice(as, func(i, j int) bool { return as[i] < as[j] })
fmt.Printf("%-38s %12.1f ns/op %9d B/op %6d allocs/op\n", name, ns[runs/2], bs[runs/2], as[runs/2])
}set -e
bin=$(mktemp -d)
GOBIN=$bin go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/[email protected]
$bin/fieldalignment ./... 2>&1 | sed -E 's#^.*/(main.go)#\1#' || true
rm -rf $binmain.go:10:15: struct of size 40 could be 24
main.go:31:24: struct of size 16 could be 8
main.go:36:12: struct of size 16 could be 12The layouts
Careless printed size 40 align 8 with offsets Active=0 ID=8 Flag=16 Count=20 Small=24 Score=32. Reading them:
Active(bool) at 0, then 7 bytes of padding becauseID(int64) needs offset 8.IDat 8.Flag(bool) at 16, then 3 bytes of padding forCount(int32) at 20.Small(bool) at 24, then 7 bytes of padding forScore(float64) at 32.Scoreends at 40.
That is 17 bytes of padding in a 40 byte struct, 42 percent of it. Ordered is size 24 align 8 with offsets ID=0 Score=8 Count=16 Active=20 Flag=21 Small=22: two 8 byte fields, then the int32, then three bools packed together, with 1 byte of padding at the end. Same data, 16 bytes less.
Mixed (byte, int16, byte, int32, byte) printed size 16 align 4 with offsets A=0 B=2 C=4 D=8 E=12. The sum of its fields is 9 bytes. Ordered D, B, A, C, E it takes 12. And struct{ A int64; E Empty } printed 16: a trailing zero size field is given one byte of space and the struct rounds up, so that taking the address of E cannot point past the object (which would keep the next object alive, or confuse the garbage collector). Moving the empty field to the front makes the struct 8 bytes.
What it saved
| Careless | Ordered | |
|---|---|---|
| Struct size | 40 | 24 |
| 4 million values | 160 MB | 96 MB |
| Allocate 1 million (ns/op) | 486,344 | 331,919 |
| Allocate 1 million (bytes) | 40,001,544 | 24,002,568 |
Sum ID over 4 million (ns/op) | 3,117,517 | 3,019,183 |
The footprint dropped by 40 percent, exactly the struct size ratio. Allocating a million values was 1.47 times faster, since the allocator zeroes and the runtime tracks 16 MB less. For a service that keeps several hundred million structs in memory, 40 percent is the difference between needing fewer machines and not.
The scan was only 3 percent faster, 3.02 ms against 3.12 ms. The program sums one int64 field across 4 million structs. Each loop iteration touches one cache line either way, and the hardware prefetcher hides the sequential access, so the speed is limited by how fast memory delivers 160 MB against 96 MB, which would suggest a bigger gap. On a machine with this much memory bandwidth, the scan took about 1 ms per 40 MB and the two sizes are close. Where padding costs speed is when the working set stops fitting a cache level, for random access over many structs (every access pulls a cache line, so denser structs mean more of them in cache), and in GC scan time for pointer containing types, since the collector walks every byte it needs to check. Measure your own access pattern: the memory saving is certain and the speed saving is not.
The tool: fieldalignment
You do not have to count offsets by hand. The fieldalignment analyzer in golang.org/x/tools reports structs whose order wastes space, and it can rewrite them:
set -e
bin=$(mktemp -d)
GOBIN=$bin go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/[email protected]
$bin/fieldalignment ./... 2>&1 | sed -E 's#^.*/(main.go)#\1#' || true
rm -rf $binmain.go:10:15: struct of size 40 could be 24
main.go:31:24: struct of size 16 could be 8
main.go:36:12: struct of size 16 could be 12The lab installs it with go install at a pinned version and runs it on the package. The three findings:
main.go:10:15: struct of size 40 could be 24(Careless)main.go:31:24: struct of size 16 could be 8(the trailing empty field)main.go:36:12: struct of size 16 could be 12(Mixed)
It also checks "pointer bytes": the part of a struct the garbage collector has to scan (everything up to the last pointer field). Moving pointer fields first reduces the scanned region, which helps GC time for large heaps. Run it as a CI step or through golangci-lint (the govet linter includes the fieldalignment check, disabled by default because its suggestions reorder fields). Running fieldalignment -fix rewrites the struct in place, which also loses any field grouping and comments, so review the diff.
When to reorder and when not to
Do it for types you create in large numbers: records in a big slice or map, cache entries, nodes in a tree or graph, events in a queue, and structs sent through channels at high volume. Do it for types that appear in a profile of memory (heap profiles).
Do not do it for configuration, request handlers, and anywhere there are a few instances. A reordering that makes a struct read worse is a bad trade when the saving is a few hundred bytes per process.
Mind readability. Fields grouped by meaning (ID, name, address) are easier to read than fields sorted by size. A comment and a one time reorder are fine. Consider splitting: a struct with a hot part and a cold part can become two structs, so the hot part is dense in cache.
Mind external layouts. A struct that is written to disk or sent over a wire with unsafe or binary.Write has its layout fixed by the format, not by the compiler's wishes. Encode field by field instead.
Mind the other direction. Padding is sometimes what you want. Two counters written by different goroutines should live on different cache lines, or they slow each other down (false sharing). That is a deliberate use of padding, covered in its own article.
Checklist
- Order fields by alignment, largest first, for types that exist in the millions.
- Use
unsafe.SizeofandOffsetofto see a layout andfieldalignmentto find the waste. - A smaller struct saves memory for certain; the speed gain depends on the access pattern.
- Put zero size fields first, not last.
- Do not reorder structs with a fixed external layout.
- Keep related fields together when the saving is negligible.
The rules are in the Go specification on size and alignment and the analyzer in the fieldalignment package.


