A Go gopher fishing at a lake, with a stack of fish beside him and one empty see-through fish on the line

While learning Go, I came across struct{}. First as a way to implement memory-efficient sets, since it takes zero bytes. Then again with goroutines, where it’s the standard way to send a signal over a channel.

Both made sense to use. What I didn’t fully understand was: how does something that takes zero bytes actually work under the hood?

Spoiler: the answer is in the runtime. Let’s dig in.

The zero-byte claim

First, let’s confirm it actually takes zero bytes. We can measure this with unsafe.Sizeof(), which returns the size of a type in bytes.

playground ▶
var i int
var s struct{}
fmt.Println(unsafe.Sizeof(i)) // 8
fmt.Println(unsafe.Sizeof(s)) // 0

We can see that struct{} takes zero bytes. As mentioned in the beginning, one place this is used is for implementing sets - since the value takes no space, the map only pays for the keys:

playground ▶
b := map[string]bool{}
s := map[string]struct{}{}

fmt.Println(unsafe.Sizeof(b["a"])) // 1
fmt.Println(unsafe.Sizeof(s["a"])) // 0

Worth noting: in modern Go (1.24+), maps use Swiss Tables internally, which means map[string]struct{} and map[string]bool end up taking the same memory in practice. The 1-byte saving no longer applies. Using struct{} for sets is now a style choice, not a memory one.

The other use case mentioned in the beginning is signaling over a channel. Let’s create a channel and confirm the elements take no space:

playground ▶
i := make(chan int)
s := make(chan struct{})
fmt.Println(unsafe.Sizeof(<-i)) // 8
fmt.Println(unsafe.Sizeof(<-s)) // 0

Since struct{} carries no data, it’s just a signal - the element takes no space.

Under the hood

We confirmed that struct{} takes zero bytes - but why? What actually happens when you create one?

var s struct{}

When a zero-size value does get heap-allocated, it goes through mallocgc. One of the first things it does is check if the size is zero - and if so, skip the allocation and just return a pointer to the zerobase variable:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // ...
    // Short-circuit zero-sized allocation requests.
    if size == 0 {
        return unsafe.Pointer(&zerobase)
    }
    // ...
}

runtime/malloc.go

zerobase is a hardcoded global variable built directly into the Go runtime - a fixed placeholder address used for every zero-byte allocation:

// base address for all 0-byte allocations
var zerobase uintptr

runtime/malloc.go

This means every struct{} variable you create points to that same address. We can confirm it:

playground ▶
var a struct{}
var b struct{}
var c [1000000]struct{}
fmt.Println(unsafe.Pointer(&a)) // 0x5a2c40
fmt.Println(unsafe.Pointer(&b)) // 0x5a2c40
fmt.Println(unsafe.Pointer(&c)) // 0x5a2c40

So the magic is simple - on allocation, the runtime just returns a pointer to the global zerobase variable, and every empty struct points to the same address.

Wait, why is it on the heap?

Why would this even call mallocgc and heap-allocate an empty struct? The answer is fmt.Println - it accepts interface{}, which causes the value to escape to the heap - a process known as escape analysis. This triggers a mallocgc call, which returns zerobase immediately.

If you use the built-in println instead which is compiled directly by the runtime and doesn’t cause escape - the structs stay on the stack:

playground ▶
func noEscape() {
    var a struct{}
    var b struct{}

    println(unsafe.Pointer(&a)) // 0x1f6c6db0f38
    println(unsafe.Pointer(&b)) // 0x1f6c6db0f38
}

func withEscape() {
    var a struct{}
    var b struct{}

    fmt.Println(unsafe.Pointer(&a)) // 0x5a2c40
    fmt.Println(unsafe.Pointer(&b)) // 0x5a2c40
}

Want to see it yourself? Compile with the -m flag to print the compiler’s escape-analysis decisions:

go build -gcflags="-m" main.go

For the snippet above you’ll see exactly what moves where:

./main.go:9:6: moved to heap: a
./main.go:10:6: moved to heap: b

noEscape() - the stack values are much higher since the stack lives at the top of the address space. Both share the same address - they take no space on the stack either.

withEscape() on the other hand uses a static global - it never goes through the heap at all. It just pulls the runtime value of zerobase from the BSS section, where static globals are stored.

Stack, heap, BSS - we’ve touched all three. Here’s how they fit together, and where an empty struct lands in each case:

Go memory layout

where each empty struct ends up in memory

Where it bites you

The fact that every empty struct shares one address is a neat trick, but the Go spec only permits this - it doesn’t promise it. That leads to two surprises worth knowing.

Pointer comparison is unreliable. Since “two distinct zero-size variables may have the same address,” the result of comparing their pointers is up to the compiler - it may alias them to zerobase or not:

var a, b struct{}
fmt.Println(&a == &b) // may print true or false

So you can’t use pointer identity to tell two zero-size values apart. Don’t build logic that depends on it.

The trailing-field trap. If a zero-size field is the last field in a struct, the runtime pads the struct with an extra word. Otherwise a pointer to that final field would point one byte past the allocation, which can keep the next object alive (or confuse the GC). So the empty struct stops being free:

playground ▶
type T struct {
    x int64
    z struct{}
}
fmt.Println(unsafe.Sizeof(T{})) // 16, not 8 - padded!

Move the zero-size field anywhere but last and the padding disappears (Sizeof goes back to 8). A small thing, but it quietly defeats the whole point of using struct{}.

Summary

struct{} costs nothing because the runtime never really allocates it. Its size is always 0, so it takes no space on the stack - and when a value escapes to the heap, the runtime skips the allocation and hands back a pointer to the global zerobase.

Two catches worth remembering:

  • Shared address. Pointers to empty structs aren’t unique, so don’t rely on comparing them.
  • Trailing field. A struct{} as the last field of a struct pads the parent - quietly costing the bytes you tried to save.

None of this changes how you use it - struct{} is still the right choice for sets and channel signals. There’s just nothing behind it, and now you know why.