technical

Aligning Go struct fields to save memory

Field order decides how much padding a Go struct carries. Two tools find the waste and reorder the fields for you.

A Go struct almost always takes up more space than the sum of its fields. The compiler inserts padding between them so each field starts at an address the CPU can read in a single step, and how much padding it adds comes down to the order you list the fields in. Change nothing but that order and the same struct can shrink by a third or more without touching a line of logic.

For a struct you build once, those wasted bytes are noise. For one you hold by the million, like telemetry events, cache entries, or graph nodes, they become real memory the program never uses: more cache misses (the CPU pulls memory in 64-byte chunks called cache lines, and fatter structs need more of them) and a bigger heap for the garbage collector to keep up with. Reordering the fields to reclaim it is about as cheap as performance work gets, and you don’t even have to do it by hand: there’s tooling that finds the waste and rewrites the struct for you.

This is the practical companion to my Memory Layout notes, which work through why the padding lands where it does and what it costs at runtime. Here I’ll keep the theory to a minimum and focus on the tools that find and fix wasteful layouts for you.

Where the bytes go

Here’s a struct that looks innocent:

type Metric struct {
    Active    bool    // 1 byte
    Timestamp int64   // 8 bytes
    Code      uint8   // 1 byte
    Value     float64 // 8 bytes
    Sampled   bool    // 1 byte
}

Nineteen bytes of real data. But int64 and float64 must start at an offset that’s a multiple of 8, so the compiler pads after each small field to line the big ones up, then rounds the whole struct up to the next multiple of 8. The result is 40 bytes, more than half of it air.

Move the wide fields to the front and the small ones pack into the leftover space:

type Metric struct {
    Timestamp int64   // 8 bytes
    Value     float64 // 8 bytes
    Active    bool    // 1 byte
    Code      uint8   // 1 byte
    Sampled   bool    // 1 byte
}

Same fields, same types, now 24 bytes. In a slice of ten million of them that’s 240 MB instead of 400 MB, plus fewer cache lines touched. Objects you allocate one at a time do even better, because the allocator rounds each one up to a fixed size class: a Metric held behind a pointer costs 48 bytes before the reorder and 24 after.

The rule of thumb is to sort fields by their alignment, 8-byte fields first and 1-byte fields last, with pointer-bearing fields (strings, slices, maps, pointers) ahead of plain numbers in each tier. That’s easy to state and tedious to apply across a real codebase. So don’t do it by hand.

Let the tooling do it

The canonical tool ships with golang.org/x/tools. fieldalignment is a vet-style analyzer that flags structs whose fields could be packed tighter, and optionally rewrites them:

go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest

fieldalignment ./...        # report wasteful structs
fieldalignment -fix ./...   # rewrite them in place

The report points straight at the offenders:

metric.go:3:13: Metric has size 40 (allocator size class 48) but the optimal size is 24 leading to a waste of 24 bytes (50%)

You’ll also see a second kind of diagnostic, about pointer bytes. When a struct holds pointers, the garbage collector only has to scan it up to the last pointer, so the tool moves pointer fields to the front to let it stop early. On real code that diagnostic fires far more often than the size one.

In report mode it’s perfectly safe. But -fix has a few sharp edges worth knowing before you turn it loose on a large repo:

  • It strips the comments off the fields it moves. Those // 8 bytes annotations, and any real documentation on a field, simply vanish.
  • It rewrites structs built with positional composite literals (Metric{true, ts, ...}) without checking for them. Reordering the fields re-maps every one of those literals. Usually that breaks the build; when the swapped fields happen to share a type, two bools say, it compiles and silently puts the wrong value in the wrong field.
  • It rewrites test files along with everything else. (Files with a Code generated ... DO NOT EDIT. header are reported but left alone.)

betteralign is a drop-in successor built on the same analyzer with those edges filed down. It preserves field comments, refuses to rewrite a struct that is built with positional literals anywhere in its own package, keeps grouped field declarations together, and skips generated and test files by default. It cannot see literals in the packages that import yours, so run go vet ./... before (its composites check flags unkeyed literals of imported structs) and go build ./... && go test ./... after. I reach for it instead of fieldalignment -fix:

go install github.com/dkorunic/betteralign/cmd/betteralign@latest

betteralign ./...          # report
betteralign -apply ./...   # rewrite in place

When a struct needs to keep its order, because it maps to a wire format or you laid it out deliberately for readability, mark it with a directive on the opening brace line and the tool leaves it alone:

type Header struct { // betteralign:ignore
    Version uint8
    Flags   uint32
    Kind    uint8
    Length  uint64
}

And if you’d rather opt in only the hot structs that matter, run it in opt-in mode and annotate them with // betteralign:check on the line above the type declaration:

// betteralign:check
type Metric struct {
    // ...
}
betteralign -opt_in ./...

That last flag is the one I reach for most. Reordering every struct in a codebase to claw back a handful of bytes from objects you allocate twice is a bad trade; opt-in mode keeps the tool pointed at the structs where packing actually pays off. It also makes a tidy CI check: in report mode the analyzer exits non-zero when an annotated struct’s field order is no longer optimal, for size or for pointer bytes. It won’t notice a struct that simply grew with its fields in the right order; for that, assert unsafe.Sizeof in a test. If your CI already runs golangci-lint, the original analyzer is one line away there in report mode (govet with fieldalignment enabled).

Where the tools stop and judgment starts

A few things no tool will decide for you:

  • Most structs don’t matter. If you build it once, like a config, a set of CLI flags, or a request you handle and discard, order the fields however reads best. Packing is for the structs you hold by the million or stream through a tight loop. Measure first: if its allocation site isn’t near the top of the heap profile’s inuse_space, leave it alone. And temper your expectations. The 40 to 24 example is contrived; on real code a reorder usually buys 8 bytes on a struct of a hundred, and most of what the tools report is pointer order, not size.
  • Sometimes you want the padding. If two goroutines hammer two fields that share a cache line, they’ll fight over it (false sharing), and the fix is to pad them onto separate lines. A padded struct that is already as small as it can be is left alone, but if the hot fields hold pointers the pointer-bytes pass will shove the padding to the end and put them back on one line. Mark those structs // betteralign:ignore. The same goes for a struct that has to match a C layout or a binary wire format.
  • 64-bit atomics have placement rules on 32-bit builds. If you use atomic.Int64 and atomic.Uint64 you’re covered; they align themselves. If you still call atomic.AddInt64 on a plain int64, keep it first, because the tool will happily put a pointer in front of it. It lays out structs for the machine it runs on, so a 64-bit laptop never sees the 32-bit layout. (Both gotchas are covered in the notes.)

Run betteralign in report mode on your hottest package once. If the report is empty you have lost five minutes. If it isn’t, you have found memory you were paying for and never used, the way a compositor spots the gap a narrower piece of type would fill.

References