Skip to content
gohacks.dev
Performance

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

9 min read
Summarize in AI
Flat illustration of a flame graph with one hot path.

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. bool and byte are 1 and 1; int16 is 2 and 2; int32 and float32 are 4 and 4; int64, float64, pointers and int on 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.

main.goGo
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))
		}
	})
}
Text
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/op
bench.goGo
package 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])
}
Shell
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 $bin
Text
main.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 12

The 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 because ID (int64) needs offset 8.
  • ID at 8.
  • Flag (bool) at 16, then 3 bytes of padding for Count (int32) at 20.
  • Small (bool) at 24, then 7 bytes of padding for Score (float64) at 32.
  • Score ends 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

CarelessOrdered
Struct size4024
4 million values160 MB96 MB
Allocate 1 million (ns/op)486,344331,919
Allocate 1 million (bytes)40,001,54424,002,568
Sum ID over 4 million (ns/op)3,117,5173,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:

Shell
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 $bin
Text
main.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 12

The 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.Sizeof and Offsetof to see a layout and fieldalignment to 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.