AlgoMaster Logo

Sizeof, Alignof & Offsetof

Low Priority18 min readUpdated June 6, 2026

The unsafe package gives you three compile-time functions that report how Go lays out values in memory: Sizeof, Alignof, and Offsetof. They don't read or write memory and they don't allocate. They tell you, at build time, how many bytes a type occupies, how that type wants to be aligned, and where each field of a struct sits relative to the start of the struct. This chapter explains what each one returns, how struct padding works, and how reordering fields can shrink a struct used in a hot path.

What the Three Functions Return

The three functions live in the unsafe package and share a common shape: each takes an expression, looks at it as a piece of compile-time type information, and returns a uintptr constant. Nothing runs at runtime. The compiler evaluates the call while building your program and substitutes the answer directly into the generated code.

unsafe.Sizeof(x) returns the size, in bytes, of the in-memory representation of x's type. It does not follow pointers. If x is a string, Sizeof(x) reports the size of the string header (a pointer plus a length), not the length of the underlying byte data.

unsafe.Alignof(x) returns the required alignment, in bytes, of x's type. Alignment is a constraint imposed by the CPU: a 64-bit integer wants to live at an address that's a multiple of 8, a 32-bit integer at a multiple of 4, and so on. Misaligned access is slower on most architectures and forbidden on a few. The Go runtime always satisfies the alignment a type asks for.

unsafe.Offsetof(s.f) returns the byte distance from the start of struct value s to the start of field f. The argument has to be a selector expression: a struct value followed by a dot and a field name. It can't be a bare type, and it can't be a field on a non-struct value.

The Order struct holds an int64, a float64, and a bool. The two 8-byte fields sit at offsets 0 and 8, the bool sits at offset 16, and the total size is 24 bytes. The bool itself is one byte, but the struct rounds up to 24 because its alignment is 8 and 17 is not a multiple of 8. That rounding is padding, and the next two sections explain why it's there.

The numbers above are for amd64. On a 32-bit system, the size of int shifts from 8 to 4, and a few alignments change as well. Anywhere this lesson lists a concrete byte count, assume amd64 unless otherwise stated.

All three return a uintptr that is itself a compile-time constant. You can use the result anywhere Go requires a constant expression, like an array size or another const declaration. That makes them useful for fixed-layout buffer code where the buffer size has to track the struct's footprint exactly.

If you change the struct, the constant updates and the array size follows automatically. The three functions live in unsafe despite being harmless to call because they expose layout details that aren't part of Go's normal semantics, and because unsafe.Pointer is the only way to use those details for real memory access.

Sizeof Stops at the Value

Sizeof reports the size of a value's own representation and stops there. It does not include the size of anything the value points to.

A string is a header (16 bytes on amd64: pointer plus length) pointing to bytes that live elsewhere. A slice is a 24-byte header (pointer, length, capacity) pointing to a backing array. A map is an 8-byte pointer to a runtime-managed hash table. In every case, Sizeof measures only the visible header.

The string is 68 characters of actual text, but the variable itself is 16 bytes. The slice has 10 elements taking 80 bytes of backing storage, but the slice header is 24 bytes. The map holds three entries plus all the hash buckets behind them, but the variable that names the map is just one 8-byte word.

This is consistent with how Go assigns and copies these types. When you write s2 := s1 on a slice, you copy the 24-byte header. Both names now point to the same backing array. The runtime only does work proportional to the header, not the data the header references, and Sizeof reflects that work.

The same rule applies to pointers. unsafe.Sizeof(p) for any pointer type is 8 on amd64, regardless of what the pointer points to.

p is a pointer, 8 bytes. *p dereferences the pointer to get the pointed-to value, an array of 10,000 int64s, which is 80,000 bytes. The two answers are wildly different because the question is. Sizeof(p) asks how big the pointer is; Sizeof(*p) asks how big the thing the pointer points to is.

Alignment and Why It Exists

Alignment is the rule that a value of a given type must start at a memory address that's a multiple of some number. An int64 wants its address to be a multiple of 8. An int32 wants a multiple of 4. A byte (or bool) can sit at any address.

Two reasons drive this. First, CPUs read memory in fixed-size chunks (a "word", typically 8 bytes on amd64). When a value sits inside one chunk, the CPU reads it in a single operation. When the value straddles two chunks, the CPU reads twice and stitches the result, which is slower. Second, some architectures (ARM in strict modes, older MIPS, some embedded targets) refuse misaligned access entirely and crash the program rather than perform it.

Go hides all of this. The compiler picks struct field offsets and the runtime allocates memory so that every value lands at an address that satisfies its type's alignment. unsafe.Alignof exposes the alignment the compiler is using.

bool aligns to 1, which is no constraint. int32 wants to start at a multiple of 4. int64, float64, string headers, and slice headers all want to start at a multiple of 8 because each one contains an 8-byte field (a 64-bit integer, a 64-bit float, or a pointer).

A struct's alignment is the maximum alignment of any of its fields. A struct with an int8 and an int64 has alignment 8. A struct of pure bools has alignment 1. This rule cascades: if you embed a struct inside another struct, the outer struct adopts at least the alignment of the inner one.

How Structs Get Padded

Once you know that each field has an alignment, struct layout follows mechanically: the compiler walks the fields in declaration order and places each one at the next address that satisfies its alignment. Any gap between the previous field's end and the next field's start is padding, which is wasted memory.

After all fields are placed, the compiler pads the end of the struct so that its total size is a multiple of the struct's own alignment. This trailing padding exists because arrays of the struct have to keep every element properly aligned. If the struct's size were 17 bytes and the alignment were 8, the second element of an array would start at byte 17, which violates the 8-byte alignment of the first field.

Take the Order struct from the first example, but now look at a wasteful version:

Placed sits at offset 0 and is one byte wide. ID needs to start at a multiple of 8, so the compiler inserts 7 padding bytes and places ID at offset 8. After ID ends at offset 16, Express (a one-byte bool) goes there. Then Total needs a multiple of 8, so 7 more bytes of padding push it to offset 24. The total is 32 bytes, and 14 of those are padding. Almost half the struct is wasted.

The same diagram in pictures:

The red blocks are pure waste. They exist only to satisfy alignment. They consume memory, they consume cache lines, and they have no useful content. For one struct in a program this doesn't matter. For a million-record in-memory product index it does.

Reordering Fields to Save Memory

The wasteful layout above can be fixed by reordering the fields from widest to narrowest. The general rule: put the largest-alignment fields first, then the smaller ones, and let the small bools cluster at the end.

The two int64-aligned fields sit back-to-back at offsets 0 and 8. The two bools pack tightly at offsets 16 and 17. The struct's alignment is 8, so the trailing padding extends it from 18 to 24. The total drops from 32 bytes to 24 bytes, a 25% saving from rearrangement alone.

The interior padding is gone. The only padding left is the 6 trailing bytes, which the struct's own alignment forces.

The often-quoted three-field example shows the same idea in a sharper form. Take a struct with a bool, an int64, and a bool:

Same three fields, same data, same alignment rules. The order changes, the size drops by a third. Wide pays 7 bytes of padding after a and 7 more bytes of trailing padding to align an array of Wide. Narrow pays nothing between b, a, and c, and only 6 bytes of trailing padding.

Whether this matters depends on how many of these things you allocate. One struct: no one cares. A slice of a million: the savings move from 24 megabytes to 16 megabytes, which can be the difference between fitting in cache and not.

Sizes of the Standard Types

Hunting padding means looking up "how big is this type, again?" a lot. The table below collects the common ones on amd64. On a 32-bit system, int, uint, uintptr, and the pointer-shaped headers are half this size.

TypeSize (bytes)Alignment
bool, int8, uint8, byte11
int16, uint1622
int32, uint32, float32, rune44
int64, uint64, float64, complex6488
int, uint, uintptr (on 64-bit)88
*T (any pointer)88
string168
[]T (any slice header)248
interface{} / any168
map[K]V88
chan T88
complex128168
func() (any function value)88

A few of these are worth a second look. A string is a (pointer, length) pair (16 bytes), a []T is (pointer, length, capacity) (24 bytes), and an interface{} is (type pointer, data pointer) (16 bytes). None count the data they reference. A map and chan are each one pointer wide because their visible value is a pointer to a runtime-managed structure. A func() value is one pointer for the function descriptor; closure state, if any, lives behind it.

These numbers are how you reason about struct layout in your head. A string field is a 16-byte block with 8-byte alignment. A []T field is a 24-byte block. An int is 8 bytes. Once you know each field's size and alignment, the struct's layout is determined.

Inspecting a Real Struct with Offsetof

unsafe.Offsetof is the tool for understanding how the compiler laid out a struct without counting bytes by hand. It's also how serialization libraries reach individual fields at known positions.

Take a realistic OrderRecord from a hot in-memory index, the kind of struct you might allocate a million of:

(The exact offsets assume time.Time is 24 bytes, which it is on the current Go runtime. If a future release adds a field to time.Time, the numbers shift, which is part of why hard-coding offsets is fragile.)

You can spot padding holes by reading the offsets. Placed is at offset 0, but ID starts at offset 8, so bytes 1 through 7 are padding. Express at offset 24 is followed by Total at offset 32, another 7 bytes of padding. CreatedAt is 24 bytes wide. Cancelled is one byte at offset 64, then the struct pads to a multiple of its alignment.

Now reorder for compactness:

Same data, eight bytes shaved off. Over a million records that's 8 megabytes of memory back, plus better cache density because more records fit into each 64-byte cache line the CPU loads at once. If two adjacent offsets differ by more than the size of the smaller field, there's padding.

One subtle rule: the argument to Offsetof must be a selector expression like r.ID. A bare type or a method call won't work, because the compiler needs the selector to compute the offset at build time.

Both forms work because both are selector expressions. The compiler doesn't need an actual allocated Cart, it only needs the type information to figure out where Total would sit.

Practical Layout: A Cache-Friendly Product Index

The reason any of this matters in real code is cache density. CPUs load memory in 64-byte chunks called cache lines. Compact a struct from 48 bytes to 32 bytes and you fit 50% more entries per cache line, which can show up as faster lookups on a hot path.

Consider an in-memory product index used by a search service. The naive layout might look like this:

48 bytes per product, partly because the bools cause padding before each wider field. Now reorder:

The packed version is 32 bytes, two products per cache line. The naive version is 48 bytes, one product per cache line plus most of a second line for the next product's first half. For an index of one million products, the packed version takes 32 MB versus 48 MB, and traversal is faster because the CPU prefetcher can keep more entries warm.

The diagram captures the win. You went from 1 record per cache line to 2, and the CPU does roughly half the memory traffic for the same logical work.

A balanced view: most structs are allocated rarely. A Customer struct that lives in a request scope and exists for milliseconds doesn't benefit from padding savings. The structs that matter are the ones allocated in bulk: index entries, cache values, log records, metrics samples. For those, run unsafe.Sizeof on the current and proposed layouts and reorder if the win is meaningful.

Quiz

Sizeof, Alignof, Offsetof Quiz

10 quizzes