betteralign reports Go structs whose field order wastes memory or GC pointer bytes, and rewrites them while preserving comments. It is a fork of, and drop-in replacement for, fieldalignment.
Differences from fieldalignment:
- Comments are preserved on rewrite (doc, line and floating comments). Go's AST does not attach comments to nodes, so
fieldalignmentdrops them. - Layout contracts are respected: a struct whose field order the code depends on (positional literals,
unsafe, atomics, codecs, assembly, conversions, …) is reported but never rewritten. See What is never rewritten. - One run reaches a fixed point: nested structs are measured at the layout the same run gives them, so a second
-fix/-applychanges nothing. - Stable sort: equal-ranked fields keep their relative order (upstream's
sort.Slicedoes not). - No crash on generic structs: upstream's
ptrdatapanics ("impossible") on a type-parameter field. - Generated files (
_generated.go,_gen.go,.gen.go,.pb.go,.pb.gw.go, or aCode generated … DO NOT EDIT.header) and test files are skipped by default. // betteralign:ignoreand opt-in (-opt_inwith// betteralign:check) annotations.- Diagnostics lead with the saving:
8 bytes saved: struct of size 24 could be 16. - Atomic file writes (not on Windows); per-pass memoisation of size/alignment/ptrdata;
GOMEMLIMITset to 90% of the cgroup or system memory.
It builds on fieldalignment (the Go Authors), maligned (Matthew Dempsky) and structslop (orijtech).
Fields are sorted stably by:
- zero-sized types first,
- higher alignment first,
- pointer-bearing types before pointer-free ones,
- among pointer-bearing types, fewer trailing non-pointer bytes first,
- larger size first.
A multi-name field (A, B int) moves as one declaration.
Size. struct { Flag bool; ID int64; Count int32 } is 24 bytes on 64-bit (7 bytes of padding before ID, 4 after Count); ID, Count, Flag is 16.
Pointer bytes. ptrdata is the prefix of a value that may hold pointers. struct { Zone string; IP []byte } is 40 bytes either way, but ptrdata is 24 with Zone first and 32 with IP first (string has 8 trailing non-pointer bytes, []byte 16). Before Go 1.26 ptrdata was also the bytes the GC scanned per value; under the Green Tea GC (default in Go 1.26) marking is span-based, and a smaller ptrdata lowers the scan-work estimate used for pacing. The diagnostic is worded for the analyzed package's Go version (the newer of its go.mod go directive and a file's //go:build go1.x).
Nested structs. A struct held by value is measured at the layout the run leaves it in, so the outer struct is laid out around the reordered inner one. Diagnostic figures use the same projected layout. Convergence assumes gofmt-clean input.
Rewriting. internal/dstmin decorates the parsed file with per-field byte spans, splices the reordered field bodies into the original source and runs go/format.Source. Each reordered struct body is cut from that output and attached as a SuggestedFix (Rearrange fields), already gofmt-aligned, so any driver that applies analysis fixes gets the same result as -apply. Shapes the splice cannot handle safely (single-line structs, fields sharing a line, a block comment crossing a brace, …) are reported without a fix. See internal/dstmin/README.md.
Download a binary from the releases page, or:
go install github.com/dkorunic/betteralign/cmd/betteralign@latestbetteralign ./... # report; exit status 3 if anything is reported
betteralign -apply ./... # rewrite files (atomic writes)
betteralign -fix ./... # rewrite via the analysis driver's suggested fixes
betteralign -apply -diff ./... # preview: gofmt-style unified diff, nothing written
betteralign -fix -diff ./... # preview in the driver's diff formatUsage: betteralign [-flag] [package]
Flags:
-V print version and exit
-all
no effect (deprecated)
-apply
apply suggested fixes
-c int
display offending line with this many lines of context (default -1)
-cpuprofile string
write CPU profile to this file
-debug string
debug flags, any subset of "fpstv"
-diff
with -fix, don't update the files, but print a unified diff
-exclude_dirs value
exclude directories matching a pattern
-exclude_files value
exclude files matching a pattern
-fix
apply all suggested fixes
-flags
print analyzer flags in JSON
-generated_files
also check and fix generated files
-json
emit JSON output
-memprofile string
write memory profile to this file
-opt_in
opt-in mode on per-struct basis with 'betteralign:check' in comment
-source
no effect (deprecated)
-tags string
no effect (deprecated)
-test
indicates whether test files should be analyzed, too (default true)
-test_files
also check and fix test files
-trace string
write trace log to this file
-v no effect (deprecated)
-applywrites each rewritten file atomically. With-diffit writes nothing and printsdiff X.orig X/--- X.orig/+++ Xhunks,Xslash-separated and relative to the working directory when under it, sogit apply -p0orpatch -p0applies them; with-jsonthe diff goes to stderr. Exit status is 3 when there are diagnostics.-fixlets the driver apply the suggested fixes (merging duplicates from a package and its test variant).-fix -diffprints the driver's diff (absolute paths,(old)/(new)headers) and exits 0.-fixoutput is never JSON.-fixtakes precedence over-apply.- With
-apply(and no-diff) no suggested fixes are attached, so a host that applies collected fixes (e.g. golangci-lint--fix) must not also set-apply. - The driver refuses fixes in generated files, so with
-generated_files,-fixrewrites through the-applypath.
go vet -vettool=$(which betteralign) works for reporting and for -fix (-fix -diff to preview; go vet rejects -diff without -fix). -apply is refused with an error: go vet runs each package in its own process, and a dependency rewritten before its importer is analyzed breaks cross-package conversions. -fix -generated_files is plain -fix there (noted on stderr). Vet the whole module: vetting only an importer (./user) reorders its side of a conversion while the partner stays. For a go.work workspace, vet one module at a time, or run betteralign itself from the root (betteralign -fix ./m1/... ./m2/...).
-generated_files,-test_files: also analyze and fix generated or test files.-exclude_dirs: a separator-free pattern (vendor,test*) matches a directory of that name at any depth; a pattern with a separator (a/internal) matches that path relative to the working directory.-exclude_files: matches the file's path relative to the working directory (p.godoes not excludep/p.go; usep/p.goor*/p.go).- A malformed pattern is a flag error (exit 2).
// betteralign:ignoreinside a struct's opening brace skips that struct.-opt_inchecks only structs whosetypedeclaration (or enclosingtype ( … )group) carries// betteralign:check.
Only named struct types (type T struct{…}, type A = struct{…}) are analyzed; anonymous structs are not.
Loading and type-checking produce mostly short-lived garbage. GOGC=off betteralign ./... was about 15% faster on a large warm-cache run (GOGC=400 about 13%). GOGC=off lets the heap grow until the GOMEMLIMIT backstop; on tight memory limits prefer GOGC=400.
Not reported at all:
- a struct with a
structs.HostLayoutfield, or a blank_field of non-zero size (deliberate padding); - a struct with an
asn1:"…"orxml:"…"tag or anencoding/asn1field type, on it or on a struct it holds by value, or with anssh/sshtypetag: their encoders follow field order and usually live in another package; - a generic struct holding a type parameter by value (
B T,B [4]T): its layout varies per instantiation (*T,[]T,map[K]Tare fine).
Reported with reorder skipped: … and no fix, because the code depends on the field order:
- a positional composite literal (
T{1, 2}) anywhere in the package, including files the current build excludes (otherGOOS/GOARCH, unset tags), in-package tests and the externalpackage p_test; unsafe.Offsetof(x.f);- a 64-bit
sync/atomicfunction on a field (atomic.AddInt64(&s.n, 1)needs 8-byte alignment on 32-bit platforms), and every struct holding such a struct by value, also when the access is in another package (os.fileholdinginternal/poll.FD); - a conversion to or from another struct type, or identity with an unnamed struct type (literal, alias, generic instance, another package's
func F(x struct{…})). A partner in another package of the same run (module,go.workworkspace, std) is predicted instead, and both sides reorder identically; unsafe.Pointerconversions (also*[]T,**T), and pointer arithmetic from a field address (uintptr(unsafe.Pointer(&p.f)) + n,unsafe.Add); a plainunsafe.Pointer(&x.f)depends only onfand pins nothing;- an argument of
encoding/binary,encoding/asn1orx/crypto/ssh, also through one local variable or one forwarding function; reflect.TypeOf(x).Field(c)/ValueOf(x).Field(c)with a constant index;- parameters, results and receivers of body-less (assembly) functions, of Go functions and variables referenced from the package's
.sfiles, and of functions a two-argument//go:linknamepushes into another package (unless that package can name the type itself: an exported type, pushed into a package outside the pusher's imports); - standard-library structs the Go toolchain lays out itself:
runtime,internal/abi,internal/runtime/maps,reflect,runtime/metrics,testing.Internal*.
Structs held by value inside a punned, encoded or assembly-accessed struct are pinned with it.
Files never rewritten: a file containing \r (normalise to LF), one with a //line directive, cgo files (import "C"; analyzed only with -generated_files, since the driver sees cgo's generated translation), and every file of a package whose directory holds an in-package _test.go that does not parse (noted on stderr).
- Positional literals in importing packages are not detected. A positional literal of an exported struct in another package is invisible to the pass that rewrites the struct: the build breaks, or values are silently mis-assigned if the types still fit. Run
go build ./...and the tests after rewriting, or convert such literals to keyed form first. - Field order stays observable.
encoding/jsonkey order,fmt's%v/%+v, reflection-based dumpers (go/ast.Print), and tests assertingunsafe.Sizeofchange with a reorder. Third-party reflection codecs, untagged structs reachingencoding/xml,reflect.FieldByIndex/NumFieldloops, values reaching an encoder throughinterface{}, cgo mirrors withoutstructs.HostLayout, syscall and wire-format structs, andOffsetofin another package are not recognised. Mark such structs// betteralign:ignoreor use-opt_in. - Generated copies are not regenerated. Generated files are skipped, so a file generated from a rewritten source keeps the old order (in std,
go/typesis generated fromcmd/compile/internal/types2). Re-run the generators (go generate ./..., or the project's equivalent) after rewriting. - Build-excluded files are scanned syntactically only. Positional literals, assembly stubs, linkname pushes and assembly references there are found; type-based contracts (
Offsetof, conversions,unsafe.Pointer, codecs) are not. Run under eachGOOS/GOARCHand tag set you ship. - In-package test literals need the test variant. The base pass leaves the rewrite to the
[p.test]variant when the directory holds an in-package test file. Under-test=falseno variant runs and the base pass rewrites; keep test loading enabled when applying. File arguments (betteralign -apply p/p.go) load no variant, so such a rewrite stays deferred (noted on stderr); run on the package instead. A struct pinned only from a test may be reported twice: plain by the base pass, with the caveat by the variant. - Cross-package predictions can miss. The local side of a conversion follows a partner predicted to move. It breaks the build when the partner stays because of a pin in its own package (literal,
Offsetof, atomics, codecs), or because an ill-typed in-package test file makes the driver skip its test variant, and when a library module is rewritten without the modules that convert to its structs. - Cross-package atomic holders are matched by field name. A same-named field of another struct over-pins; under
-trimpath, areplaced or othergo.workmodule is not resolved and pins nothing. - Projected figures can be off. A struct whose field names are all exported must sort identically in every package, so an inner struct pinned in its own package is measured as reordered although it stays (in std,
compress/gzip.Headergoes from 72 to 80 pointer bytes, sincetime.Timeis pinned intime). A struct with an unexported field is measured against its package's pins; if the inner pin is only in an in-package test, the base pass keeps reporting the outer struct without a fix. The code stays correct;-opt_inmeasures every inner struct as declared. - A leading UTF-8 BOM is dropped by
-applyand-fix(gofmt removes it). - False sharing. The most compact order can put fields written by different goroutines on one cache line.
Rewriting Go 1.27.3's std and cmd: everything builds for 21 GOOS/GOARCH targets, a second run changes nothing, and go test -short std cmd (amd64; std also on 386) fails only where tests compare field-order-dependent output: go/ast ExamplePrint, net/http TestTransportEventTrace (%+v), cmd/compile/internal/logopt and a cmd/go script (JSON key order), types2 TestSizeof, and go/types TestGenerate (generated copies).
