Aligning Go struct fields to save memory

Aligning Go struct fields to save memory

June 28, 2026
A printer's composing case: a wooden tray divided into compartments of different sizes, each holding a sort of movable type. The compositor packs the pieces so no space is wasted.

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. Same fields, same types, not a line of logic touched.

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 lines to fault in, and more for the garbage collector to scan. 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: alignment rules, cache lines, the GC, false sharing. Here I’ll keep the theory to a minimum and focus on the tools that find and fix wasteful layouts for you.

A quick refresher

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. Across ten million elements that’s the difference between 400 MB and 240 MB, plus fewer cache lines touched and less for the garbage collector to scan. (The notes walk through exactly where each byte goes.)

The rule of thumb, order fields from widest to narrowest, is 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: struct of size 40 could be 24

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, ...}). Reordering the fields silently re-maps every one of those literals to the wrong field, so you get code that still compiles and now does the wrong thing.
  • It rewrites test and generated files along with everything else.

betteralign is a drop-in successor built on the same analyzer with those edges filed down. It preserves field comments, refuses to rewrite structs constructed with positional literals, keeps grouped field declarations together, and skips generated and test files by default. 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 and the tool leaves it alone:

type Header struct {
    // betteralign:ignore
    Version uint8
    _       [3]byte
    Flags   uint32
}

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:

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 something could be packed tighter, so a change that bloats one of your annotated structs fails the build.

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 it’s not in your heap profile, leave it alone.
  • 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, the opposite of what these tools do. 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. A field you touch with sync/atomic may need to stay first. Reordering tools don’t know that; you do. (Both gotchas are covered in the notes.)

References