# Go Perf Struct Layout

> Guides Go struct layout for performance — field alignment and padding, how field ORDER changes its size, ordering fields largest-to-smallest to shrink it (measured unsafe.Sizeof deltas), the fieldalignment analyzer and -fix, unsafe.Sizeof/Alignof/Offsetof, and why pointer-free structs scan faster under GC. Fires on "shrink this struct", "struct padding", "fieldalignment", "why is this struct so big", "align struct fields". Routes GC-scan to go-perf-allocator-internals, false sharing to go-perf-false-sharing.

- Skill: `ctoth/go-perf-struct-layout` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-perf-struct-layout`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-perf-struct-layout/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-perf-struct-layout

---


# Go Struct Layout

> "The compiler adds padding … to bring its size up to a multiple of the largest element in the structure … if you are looking to optimise the memory usage of your program, you need to be looking closely at the definitions of all your data structures."
> — Dave Cheney, [Padding is hard](https://dave.cheney.net/2015/10/09/padding-is-hard)

Struct **correctness and idiom** (field naming, embedding, tags, zero-value design) belong to **`go-naming-and-style`** and **`go-zero-values-and-construction`** — read those for "how should this struct look." This skill owns one narrow performance fact: **the order you write fields changes how many bytes the struct occupies and how much the GC must scan**, and you can shrink a hot struct by reordering alone — same fields, same behavior, fewer bytes.

Whether you *should* reorder is a `go-perf-methodology` question (§5); the numbers below are real, measured under `go1.26.0`.

All Go below builds clean under `gofmt`, `go vet`, and `go test` on Go 1.26.

---

## 1. The Alignment Rules (the whole model)

Two guarantees from the language spec produce every padding byte you will ever see.

**Sizes are fixed.** For the numeric types the spec guarantees: `byte, uint8, int8` = 1; `uint16, int16` = 2; `uint32, int32, float32` = 4; `uint64, int64, float64, complex64` = 8; `complex128` = 16 ([Go spec, Size and alignment guarantees](https://go.dev/ref/spec#Size_and_alignment_guarantees)). On a 64-bit target a pointer, `int`, `uintptr`, map, and chan are 8; a `string` is 16 (ptr+len); a slice is 24 (ptr+len+cap).

**Alignment is the rule that forces padding.** The spec's minimal guarantees ([Go spec](https://go.dev/ref/spec#Size_and_alignment_guarantees)):

1. *For a variable `x` of any type: `unsafe.Alignof(x)` is at least 1.*
2. *For a variable `x` of struct type: `unsafe.Alignof(x)` is the largest of all the values `unsafe.Alignof(x.f)` for each field `f` of `x`, but at least 1.*
3. *For a variable `x` of array type: `unsafe.Alignof(x)` is the same as the alignment of a variable of the array's element type.*

From these, two consequences drive everything:

- **A field of alignment `a` must start at an offset that is a multiple of `a`.** A `int64` (align 8) cannot start at offset 1; the compiler inserts padding bytes before it. Measured: `Alignof(bool)=1`, `Alignof(int32)=4`, `Alignof(int64)=8`, `Alignof(float64)=8`, pointer align `=8`.
- **The struct's size rounds up to a multiple of the struct's own alignment** (rule 2: the max field alignment) — so an array of the struct keeps every element aligned. That is the *trailing* padding ([Padding is hard](https://dave.cheney.net/2015/10/09/padding-is-hard)).

One layout fact has a correctness edge: on **32-bit platforms** a 64-bit field accessed with `sync/atomic` must be 64-bit aligned, and the spec only guarantees alignment for "the first word in … an allocated struct, array, or slice" — so a misordered `int64`/`uint64` atomic field can panic on 32-bit. Put such fields first, or use the typed `atomic.Int64` (which carries its own alignment). The correctness rule is **`go-sync-primitives`**' / **`go-perf-atomics-vs-locks`**' to own; layout is just the lever.

---

## 2. Field Order Changes the Size — Measured

Same fields, two orders. Three `bool`s interleaved with two `int64`s:

```go
type Bad struct { // 19 bytes of data, but...
	a bool  // offset 0
	b int64 // offset 8  (7 pad bytes after a — int64 must land on a multiple of 8)
	c bool  // offset 16
	d int64 // offset 24 (7 pad bytes after c)
	e bool  // offset 32 (7 trailing pad bytes → size rounds to 40)
}

type Good struct { // identical fields, largest-alignment first
	b int64 // offset 0
	d int64 // offset 8
	a bool  // offset 16
	c bool  // offset 17
	e bool  // offset 18 (5 trailing pad bytes → size rounds to 24)
}
```

Measured with `unsafe.Sizeof` under `go1.26.0`:

| Struct | `unsafe.Sizeof` | Wasted padding | Field offsets |
|---|---|---|---|
| `Bad`  | **40** | 21 bytes | a=0, b=8, c=16, d=24, e=32 |
| `Good` | **24** | 5 bytes | b=0, d=8, a=16, c=17, e=18 |

The same layout drawn byte-by-byte makes the holes literal (`.` = padding):

```text
Bad  (40):  a . . . . . . . | b b b b b b b b | c . . . . . . . | d d d d d d d d | e . . . . . . .
            0               8                 16                24                32
Good (24):  b b b b b b b b | d d d d d d d d | a c e . . . . .
            0               8                 16
```

**A 40% reduction (40 → 24) from reordering alone** — no field added or removed. A second, smaller case: `struct{ a bool; b int32; c bool }` measures **12** bytes, but `struct{ b int32; a bool; c bool }` measures **8** — a 33% cut. The rule of thumb: **order fields from largest alignment to smallest** and the holes collapse. (Exception: when a field must be cache-line isolated to avoid false sharing → §6.)

---

## 3. The `fieldalignment` Analyzer (and `-fix`)

Don't compute padding by hand — the analyzer does it and rewrites the struct. It "finds structs that would use less memory if their fields were sorted … and provides a suggested edit with the most compact order" ([fieldalignment](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/fieldalignment)). It ships *outside* `vet`/`gopls` because its findings "very rarely indicate a significant problem," so install and run it standalone ([fieldalignment](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/fieldalignment)):

```sh
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
fieldalignment ./...        # report only
fieldalignment -fix ./...   # rewrite structs to the compact order
```

Run against the `Bad` struct above, the real output was:

```text
s.go:3:10: struct of size 40 could be 24
```

and `-fix` rewrote it to `b int64; d int64; a bool; c bool; e bool` — exactly largest-first, matching `Good` at 24 bytes. **Always re-run your tests after `-fix`**: reordering is safe for in-memory size but changes binary field offsets, so it can break code that depends on layout (`unsafe`, `cgo`, `encoding/binary` on the struct, reflection that assumes an order).

---

## 4. `unsafe.Sizeof` / `Alignof` / `Offsetof` — Prove It, Don't Guess

Never assert a struct's size from memory; measure it. These are **compile-time constants** (for fixed-size types), so they cost nothing at runtime ([unsafe](https://pkg.go.dev/unsafe#Sizeof)):

- `unsafe.Sizeof(x)` — "the size in bytes of a hypothetical variable `v` … For a struct, the size **includes any padding** introduced by field alignment." It does **not** include memory referenced *through* the value — `Sizeof` of a slice is the 24-byte descriptor, not the backing array ([unsafe](https://pkg.go.dev/unsafe#Sizeof)).
- `unsafe.Alignof(x)` — "the required alignment" (rule 2 above); for a struct field `s.f` it returns the field's in-struct alignment ([unsafe](https://pkg.go.dev/unsafe#Alignof)).
- `unsafe.Offsetof(s.f)` — "the number of bytes between the start of the struct and the start of the field" — the direct way to *see* the padding holes ([unsafe](https://pkg.go.dev/unsafe#Offsetof)).

```go
fmt.Println(unsafe.Sizeof(Bad{}))         // 40
fmt.Println(unsafe.Offsetof(Bad{}.b))     // 8  — 7-byte hole after the leading bool
fmt.Println(unsafe.Sizeof([]byte(nil)))   // 24 — slice header only, not the data
```

A growing offset gap between adjacent fields *is* the padding; that is the cheapest diagnostic.

---

## 5. When Smaller Structs Actually Pay (and when not)

Shrinking a struct helps three ways, all proportional to how *hot* and *numerous* it is:

- **Cache footprint.** A 64-byte cache line holds one 40-byte struct but *two* 24-byte ones. Iterating a `[]T` of the smaller struct touches roughly half the cache lines, so the prefetcher and L1/L2 do less work — the win shows up on large slices, not on a single value.
- **Fewer allocator bytes.** A smaller struct may drop into a smaller size class, so each heap allocation costs less and the heap grows slower (size-class mechanics → **`go-perf-allocator-internals`**).
- **Less GC scan work** when the shrink also removes pointers (§6).

**The tradeoff, explicitly:** reordering trades the *readable* grouping of fields (logical clusters, related fields together) for compactness. For a struct that is **rare, short-lived, or not on a hot path**, the padding is irrelevant and you should keep the order that reads best — `go-perf-methodology`'s "is this worth optimizing?" gate applies. Reorder for size only when a profile or an allocation count says this struct's footprint matters; otherwise the clarity loss is unpaid-for. Don't sprinkle `fieldalignment -fix` across a whole codebase.

---

## 6. Pointer-Free Structs Scan Faster Under GC

Beyond raw size, the GC has to **scan** a struct for pointers — and field layout decides how much. `fieldalignment` reports a second diagnostic, "pointer bytes": "how many bytes of the object the garbage collector has to potentially scan for pointers." Its own examples: `struct { uint32; string }` "have 16 pointer bytes because the garbage collector has to scan up through the string's inner pointer," while `struct { string; uint32 }` "has 8 because it can stop immediately after the string pointer" ([fieldalignment](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/fieldalignment)). (Both measure 24 bytes via `unsafe.Sizeof` — same size, different *scan* cost.)

The strongest version: a struct with **no pointer fields at all** (only fixed-width numerics/bools/arrays of such) is allocated in a *no-scan span* the GC never walks. The fastest hot struct is therefore both **small and pointer-free** — e.g. store an index `uint32` into a slab instead of a `*T`. The allocator-side mechanics (scan vs no-scan spans, the bitmap) are **`go-perf-allocator-internals`**; this skill's rule is just: *if a struct can be pointer-free, the GC stops touching it.*

---

## 7. Don't

- **Don't guess a struct's size** — measure with `unsafe.Sizeof`; it includes padding ([unsafe](https://pkg.go.dev/unsafe#Sizeof)).
- **Don't leave a hot, numerous struct in declaration order** when `fieldalignment` says it "could be" smaller — interleaved small/large fields waste bytes (§2).
- **Don't reorder a rare or cold struct for size** at the cost of readability — the padding is free there; let methodology gate it (§5).
- **Don't run `fieldalignment -fix` and skip the tests** — it changes field offsets, which breaks `unsafe`/`cgo`/`encoding/binary`/reflection that assumes a layout (§3).
- **Don't assume reordering changes GC cost** — it changes *size*; only *removing pointers* removes scan work (§6 → `go-perf-allocator-internals`).
- **Don't blindly pack the most compact order on a concurrently-written struct** — adjacent hot fields can land on one cache line and false-share; that needs *separation* padding, the opposite move (§ → `go-perf-false-sharing`).
- **Don't `Sizeof` a slice/map/string and think you measured the data** — you measured the header ([unsafe](https://pkg.go.dev/unsafe#Sizeof)).

---

## 8. Routing to Related Skills

**This plugin (`go-perf-*` depth):**

- `go-perf-allocator-internals` — size classes, the tiny allocator, and scan-vs-no-scan spans (why pointer-free structs skip GC scanning).
- `go-perf-false-sharing` — padding to *separate* concurrently-written fields onto distinct cache lines (`cpu.CacheLinePad`) — the inverse of compacting.
- `go-perf-data-oriented-layout` — struct-of-arrays vs array-of-structs, intrusive/ring structures, cache-friendly access patterns.
- `go-perf-methodology` — the "is shrinking this struct worth it?" decision and the measure-first gate.

**Marketplace (`go-*` correctness):**

- `go-naming-and-style` — struct field naming, embedding, tags, idiomatic shape.
- `go-zero-values-and-construction` — useful zero values and constructor design (layout serves these, never overrides them).
- `go-slices-and-maps` — slice/map header semantics behind the `Sizeof`-header note.

---

## 9. Reference Files

Wrong/right struct-layout mistakes in LLM-generated Go, each with a citation:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

