Sitemap

The Hidden Cost of a Go Struct

6 min readAug 31, 2026

--

Your 36 bytes of data are occupying 64 bytes of heap. Here’s where the rest went.

A struct in Go feels like the cheapest thing in the language. You declare a few fields, the compiler lays them out, and you move on. No hidden vtable, no constructor, no metadata header. What you write is what you get.

Except it isn’t, quite.

I ran into this while working on a service that buffered incoming events in memory before flushing them in batches. The struct held six fields. I added them up on paper: 36 bytes. With a couple million events buffered, I expected roughly 72 MB of live data. The process was sitting closer to 130 MB.

The struct hadn’t grown. I just didn’t know what I was actually paying for.

The struct

Here’s a cleaned-up version of it:

type Event struct {
Active bool // 1 byte
ID int64 // 8 bytes
Kind uint8 // 1 byte
Duration time.Duration // 8 bytes
Name string // 16 bytes
Retries uint16 // 2 bytes
}

Add the field sizes: 1 + 8 + 1 + 8 + 16 + 2 = 36.

Now ask Go:

fmt.Println(unsafe.Sizeof(Event{})) // 56

Twenty bytes I never wrote are sitting inside every single event.

Where the bytes went: alignment

The CPU doesn’t read memory one byte at a time. It reads aligned words. An int64 wants to start at an address divisible by 8; an int32 at an address divisible by 4. If a field would land on an address that doesn't satisfy its alignment, the compiler inserts padding bytes in front of it until it does.

Go lays out fields in the exact order you declare them. It will never reorder them for you. So the declaration order above produces this:

offset  0: Active   (bool, 1 byte)
offset 1: [7 bytes padding]
offset 8: ID (int64, 8 bytes)
offset 16: Kind (uint8, 1 byte)
offset 17: [7 bytes padding]
offset 24: Duration (int64, 8 bytes)
offset 32: Name (string, 16 bytes)
offset 48: Retries (uint16, 2 bytes)
offset 50: [6 bytes tail padding]
-----------------------------------
total: 56 bytes

Two things are happening. The first is internal padding: bool and uint8 each got followed by seven wasted bytes so that the 8-byte field after them could land on an 8-byte boundary. The second is tail padding. The struct's own alignment is the largest alignment of any field in it, which is 8 here, so the total size gets rounded up to a multiple of 8. That's the last six bytes.

Tail padding exists so that arrays work. If Event were 50 bytes, then in a []Event the second element would start at offset 50 and its ID field would be misaligned. Rounding the struct up keeps every element in a slice correctly aligned.

Reordering costs nothing and gets it back

Declare the fields from widest to narrowest:

type Event struct {
Name string // 16 bytes
ID int64 // 8 bytes
Duration time.Duration // 8 bytes
Retries uint16 // 2 bytes
Active bool // 1 byte
Kind uint8 // 1 byte
}
offset  0: Name     (16 bytes)
offset 16: ID (8 bytes)
offset 24: Duration (8 bytes)
offset 32: Retries (2 bytes)
offset 34: Active (1 byte)
offset 35: Kind (1 byte)
offset 36: [4 bytes tail padding]
-----------------------------------
total: 40 bytes

Same fields, same types, same semantics. 56 bytes down to 40, a 29% reduction, for the price of moving three lines. The small fields now sit together and share a single stretch of padding instead of each demanding their own.

The rule of thumb: group fields by size, largest first. Pointers, strings, slices, maps, interfaces, and 64-bit numbers at the top; int32 and int16 in the middle; bool and uint8 at the bottom.

The allocator has opinions too

Saving 16 bytes on a slice element is exactly 16 bytes per element, because a slice is one contiguous block. But if you’re allocating events individually, the savings are different, and they’re larger than you’d guess.

Go’s allocator doesn’t hand out arbitrary sizes. It rounds every small allocation up to one of a fixed set of size classes: 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, and so on. There’s no 56-byte class and no 40-byte class.

  • The 56-byte struct lands in the 64-byte class. 8 more bytes lost.
  • The 40-byte struct lands in the 48-byte class. 8 more bytes lost.

So per heap-allocated event, the real cost is 64 bytes versus 48. You wrote 36 bytes of data. In the first version you’re paying 64 for it. That’s 78% overhead, and none of it shows up in your type declaration.

Get Aditya Padekar’s stories in your inbox

Join Medium for free to get updates from this writer.

At two million events, that’s 128 MB versus 96 MB. Which is roughly the gap I couldn’t explain.

Pointers cost more than eight bytes

The size number isn’t the whole bill. The garbage collector has to walk every object that might contain pointers, and whether your struct contains one changes how it’s stored.

Go segregates the heap into spans that contain pointers and spans that don’t. An object with no pointer fields anywhere inside it goes into a noscan span, and the GC never traverses it during marking. It just skips the whole region.

string contains a pointer. So does []byte, map, chan, interface{}, and obviously *T. One string field is enough to move your struct into scannable territory, which means every mark phase walks over two million of them chasing that one pointer each.

If you’re holding a very large number of small records and the string is short and bounded, a fixed array can be worth considering:

type Event struct {
ID int64
Duration time.Duration
Name [24]byte // no pointer, no GC scan, no separate allocation
Retries uint16
Active bool
Kind uint8
}

That struct is entirely pointer-free. The GC allocates it, and then ignores it forever. You also drop the separate heap allocation that backed each string.

This is a real trade. You lose variable length, you gain truncation bugs, and the code gets uglier. It’s worth doing when profiling says GC mark time is your problem, and it’s a bad idea otherwise. But knowing the mechanism is what lets you make the call.

The copy tax

Struct size shows up again every time the value moves. Go copies structs by value on assignment, on function calls with value receivers, on channel sends, and on range.

for _, e := range events { // copies 56 bytes per iteration
process(e)
}
for i := range events {    // copies nothing
process(&events[i])
}

For two million elements, the first loop moves 112 MB through memory to do nothing but read a field. Shrinking the struct makes the copy cheaper; not copying makes it free.

Related trap, and this one bites people: a value receiver gets a copy, so mutations inside it are discarded. That’s a whole separate class of bug, and it’s the reason sync.Mutex inside a struct must always be reached through a pointer.

Boxing into an interface

One more place structs get expensive quietly:

var x interface{} = event // heap allocation + copy

An interface value is two words: a type pointer and a data pointer. Anything larger than a word can’t live inline, so assigning a struct to an interface copies it to the heap. This is what makes log.Printf("%v", event) in a hot loop more expensive than it looks, and it's why -benchmem sometimes reports allocations in functions that appear to allocate nothing.

How to actually find this

Don’t eyeball offsets. Use tools:

fieldalignment, part of the official analysis tooling, flags every struct that would shrink if reordered and tells you the new size:

go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
fieldalignment ./...

golangci-lint can run it as part of govet, so it becomes a CI check rather than a thing you remember to do.

unsafe.Sizeof, unsafe.Alignof, unsafe.Offsetof answer the question directly in a scratch program. They're compile-time constants and involve no unsafe behaviour despite the package name.

go test -bench . -benchmem tells you bytes and allocations per operation, which is where interface boxing and accidental escapes surface.

go build -gcflags="-m" reports escape analysis decisions, so you can see which values are going to the heap and why.

The part where I tell you not to do this everywhere

Reordering fields on a struct you instantiate a dozen times is a waste of a code review. Padding is only interesting at scale: large slices, high-throughput allocation, cache-sensitive inner loops, anything you hold millions of.

For everything else, declare fields in whatever order makes the type readable. Grouping related fields together is worth more to the next person reading it than 16 bytes you’ll never notice.

But when memory does matter, it’s worth knowing that a Go struct isn’t just the sum of its fields. It’s the sum of its fields, plus the alignment padding between them, plus the tail padding after them, rounded up to a size class, multiplied by how many you keep alive, and scanned by the GC on every cycle if any part of it contains a pointer.

None of that is visible in the type declaration. It’s all visible in the memory graph.

--

--