Detect and fix struct field alignment to reduce memory usage in Go programs
Detect and fix struct field alignment to reduce memory usage in Go programs
betteralign is a tool that detects structs that would use less memory if their fields were reordered, and optionally rewrites them.
It is a fork of the official Go fieldalignment tool, with the bulk of the alignment logic unchanged. Its notable differences group into a few themes:
Why this exists
fieldalignment erases them on rewrite.Safety and robustness
T{1, 2, 3} rather than T{a: 1, b: 2, c: 3}) anywhere in the package and reports the would-be saving without rewriting the struct — reordering its fields would silently re-map the literal's elements at the next build,type T struct { ... }; anonymous structs (nested struct-typed fields, struct literals, var x struct{...} declarations) are skipped, so it never rewrites the riskier unnamed shapes,-apply rewrites a struct, fields declared with multiple names (A, B int) are kept together as a single grouped declaration and moved as a unit — the byte-span reorder never splits them,sort.Slice is unstable and may permute equal-ranked fields differently between runs,ptrdata implementation ends with panic("impossible"), which fires when an uninstantiated type parameter appears as a field type (because (*types.TypeParam).Underlying() returns itself, falling through every switch case); betteralign replaces the panic with a conservative one-word fallback.Opt-in / opt-out controls
_generated.go, _gen.go, .gen.go, .pb.go, .pb.gw.go) or by a package-level comment containing Code generated by ... DO NOT EDIT.,_test.go suffix),// betteralign:ignore placed inside the struct body,// betteralign:check on the type declaration are checked; placing the directive on a surrounding type ( ... ) block opts in every spec inside the group.Other improvements
8 bytes saved: struct of size 24 could be 16) rather than only the before/after sizes,Comment preservation is achieved by a small in-tree package, internal/dstmin, that decorates the parser's *ast.File with byte-range spans for each field's lead-doc, body and trailing blanks, then reprints the file by byte-splicing the synthesized struct bodies into the original source and finalising with go/format.Source for column realignment. It replaces a previous dependency on sirkon/dst with ~900 lines of focused code; see internal/dstmin/README.md for the design rationale and benchmark comparison. The whole-file rewrite trade-off still applies: partial rewrites via SuggestedFixes are not possible, so the -fix flag from the analysis package is treated as an alias for -apply, and auto-fix integration with golangci-lint is not supported.
Go's standard AST does not associate comments with nodes — it only stores byte offsets, so the original fieldalignment tool erases all comments. A proposed fix exists as an open CL but has not yet been merged.
Note: This is a single-pass tool. Achieving a fully optimal layout may require running it more than once.
This tool builds upon the following prior work:
betteralign pursues two goals:
ptrdata-based scan-work estimate the GC uses for pacing by grouping pointer-bearing fields before pointer-free ones, shrinking each value's ptrdata (the head prefix the runtime records as potentially holding pointers). On Go before 1.26 this also directly reduced the bytes scanned per value, since the collector stopped scanning at the last pointer in a value; under the Green Tea GC (default in Go 1.26) marking is span-based rather than per-value, so the win is the smaller ptrdata feeding GC pacing rather than fewer bytes physically scanned.With those goals in mind, fields are sorted stably by the following criteria, in order:
ptrdata).type Record struct {
Flag bool // 1 byte, 1-byte aligned
ID int64 // 8 bytes, 8-byte aligned
Count int32 // 4 bytes, 4-byte aligned
}As declared, Record is 24 bytes on a 64-bit platform: Flag sits at offset 0, then 7 padding bytes appear so ID can land on an 8-byte boundary; Count follows at offset 16, and the struct is rounded up to the next multiple of its highest alignment (8), adding 4 trailing padding bytes.
After optimalOrder sorts by descending alignment, the layout becomes ID, Count, Flag and shrinks to 16 bytes: ID at offset 0, Count at offset 8, Flag at offset 12, plus 3 trailing padding bytes to round to 16. No internal padding is needed because each field already lands on a correctly-aligned offset. Same data, 33% less memory.
ptrdata: ordering pointer-bearing fields by trailing non-pointer bytestype IPAddr struct {
Zone string // 16 bytes, 8-byte aligned
IP []byte // 24 bytes, 8-byte aligned
}Zone comes first because string has fewer trailing non-pointer bytes than []byte: Sizeof(string) - ptrdata(string) = 8 versus Sizeof([]byte) - ptrdata([]byte) = 16. The struct is 40 bytes either way on a 64-bit platform, but with Zone first the value's ptrdata is only 24 bytes; reversing the fields would push it to 32. That ptrdata feeds the GC's scan-work estimate used for pacing — and, on Go before 1.26, the number of bytes the collector scanned per value. Under the Green Tea GC (default in Go 1.26) marking is span-based, so the smaller ptrdata lowers the pacing estimate rather than the bytes physically scanned.
The pointer-bytes diagnostic reflects this: betteralign reads the Go version of the project being analyzed (its go.mod go directive, or a per-file //go:build go1.x constraint). For a target on Go 1.26 or newer it reports the reorder as lowering the GC scan-work estimate; for older targets it keeps the classic N bytes saved framing, which there still maps to bytes physically scanned. Struct-size diagnostics are unaffected — that saving is real memory regardless of GC.
GOGCThe analyzer itself is allocation-light, but loading and type-checking a package graph (go/types, the AST inspector, and the parser) produces a large volume of short-lived garbage — on a real-world run, well over 1 GiB of churn against only a few MiB of live heap at exit. Because betteralign is a short-lived batch process, the default GOGC=100 spends CPU collecting garbage that would be freed at exit anyway. Running with garbage collection disabled trades that work for higher peak memory:
GOGC=off betteralign ./...On a large warm-cache run this measured roughly a 15–16% wall-clock improvement (and GOGC=400 about 13%), since most of the runtime's GC work simply disappears.
[!CAUTION]
GOGC=offlets the heap grow unbounded for the duration of the run. In memory-constrained containers (e.g. a Kubernetes pod with a low memory limit) this can trigger an OOM kill on a large package graph.betteralignalready sets a softGOMEMLIMIT(90% of the cgroup/system memory) which acts as a backstop, but on tight limits prefer a high finite value such asGOGC=400over fully disabling collection, or leave the default.
Manual: Download the appropriate binary from the releases page and place it in your PATH, typically /usr/local/bin/betteralign.
Via go install:
go install github.com/dkorunic/betteralign/cmd/betteralign@latest…To check all packages in the current module:
betteralign ./...To automatically rewrite files (excluding test and generated files):
betteralign -apply ./...Generated and test files can be included with the -generated_files and -test_files flags respectively. Use -exclude_dirs and -exclude_files to skip specific paths. For -exclude_dirs, a separator-free pattern (vendor, internal, test*) matches a directory of that name at any depth, gitignore-style, while a pattern containing a separator (a/internal) matches only that exact path relative to the working directory. Use -opt_in to check only structs explicitly annotated with // betteralign:check.
Structs constructed via positional composite literals are reported but never rewritten under -apply; the diagnostic points at the offending literal so it can be converted to keyed form (T{Field: value}), after which a rerun will enable the reorder.
package p_test) is invisible to the pass that rewrites the struct, because Go's static-analysis facts flow with the import graph, not against it. If such a literal exists, -apply will reorder the struct and either break the build or — when the reordered field types still accept the old element types — silently mis-assign values with a clean compile. This is the most dangerous outcome the tool can produce. Unexported structs are safe (their positional usage is confined to the defining package). When running -apply ./... across a multi-package module, gate it with a go build ./... (and your test suite) afterwards, or convert exported structs' positional literals to keyed form first.*_test.go keys on test files actually loaded by the driver. Running with the driver's -test=false (which loads no test files) defeats it, so -apply may rewrite a struct pinned by sucNo open issues yet, or sync has not completed.