Hash tables in Go and advantage of self-hosted compilers

Last updated on December 14, 2025, in go

Recently, I was looking at the Go codebase that was using map[int]bool to track unique values. As some of you may know, Go has no set data structure. Instead, developers usually use hash maps (key-value data structure).

The first idea that comes to mind is to use map[int]bool, where keys are integers and values are booleans. But experienced Go developers know that Go has an empty-struct type that can be used for maps: map[int]struct{}.

The benefit of such a type is that it occupies zero bytes in memory; it's a so-called zero-sized type. The compiler knows this and uses it to your advantage when it can. In this case, it should omit storing values and keep only keys.

So when I saw the bool struct, my first thought was to switch to map[int]struct{} to save some memory. In theory, that map can hold more than 100 000 integers.

To my surprise, this change had no effect on memory consumption when running in production.

Debugging the issue

I double-checked that using struct{} should save memory in Google and with LLMs. It was, in fact, true. After a bit more searching, I found that since Go 1.24, there is a new map implementation called Swiss Tables. A lot of articles, including the official Go blog, say that the new implementation consumes less memory on average (for all use cases).

If you specifically ask LLMs about Go 1.24 and map[int]struct{} (with web search enabled), they will assure you that using empty maps consumes less memory.

So what's actually happening?

What is self-hosted compiler?

Not to be confused with self-hosting a compiler, a self-hosted compiler means that the compiler itself is written in the same language. So, the current compiler for Go is also written in Go.

This has a big advantage. Since Go is a relatively simple language, we can quickly check how some of the parts are actually implemented.

I had an experience of looking at Python's dict implementation, and I can say that Go's source code is much easier to understand. Especially given that it's hard for a Python programmer with little C experience to understand complex C.

Go implementation

After looking at the map implementation, I found the structure of each slot (key and value pair):

type slot struct {
    key  typ.Key
    elem typ.Elem
}

Which can be translated to this:

type slot struct {
    key  int
    elem struct{}
}

The key requires 8 bytes, and the elem is zero since it's an empty struct type.

But here is a catch. The amount of memory that a struct uses is not always equals to the sum of its fields. Structs need padding to ensure proper memory alignment for CPU.

If the last field of a struct is zero, Go compiler makes its size 1 byte so that the field can be actually referred in memory by pointer arithmetic without violating memory access (out of bounds reads).

Go specs:

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.

Now, since the first item is 8 bytes and our struct is 1 byte, we also have to align our empty struct. We need this so that the structure have proper alignment - multiple of 8. We do this by adding 7 bytes to the end that won't be used.

You can see the actual size here.

How about bool?

Boolean type in Go also occupies 1 byte and follows the same alignment rules described above.

That's why we get the same memory consumption.

Why did struct{} work before?

Prior to Go 1.24, maps were implemented differently.

Here is how it looked like:

type bmap struct {
    // tophash generally contains the top byte of the hash value
    // for each key in this bucket. If tophash[0] < minTopHash,
    // tophash[0] is a bucket evacuation state instead.
    tophash [abi.MapBucketCount]uint8
    // Followed by bucketCnt keys and then bucketCnt elems.
    // NOTE: packing all the keys together and then all the elems together makes the
    // code a bit more complicated than alternating key/elem/key/elem/... but it allows
    // us to eliminate padding which would be needed for, e.g., map[int64]int8.
    // Followed by an overflow pointer.
}

Prior to Go 1.24, keys and values are stored as separate fixed-size arrays. One array for keys, followed by second array for values.

When using struct{}, compiler omits the values array completely.

Conclusion

I think a lot of people still rely on this trick, but in newer versions of Go it won't work (at least for now). Using empty structs also hurts readability.

Another takeaway here, as always, is not to trust everything LLMs say.


If you have any questions, feel free to ask them via e-mail displayed in the footer.

Comments

  • JetLu 2025-12-15 #

    Thanks!

    reply

  • Coraline 2025-12-20 #

    I love the Go syntax and mindset, but I hate compiler "magic" like this. It's why I can't use Go seriously, and instead have to use Rust. Rust is WYSIWYG.

    reply

    • Will C-S 2025-12-21 #

      While the particular case of zero-sized types in containers is more straightforward in Rust, the compiler (both the self-hosted half and the part that is LLVM) very much also does quite a bit of compiler magic around memory layout and alignment. For instance, pretty much the only guarantee related to enum types is that things that are basically Option of a pointer will have the None take the null pointer value.

      reply

  • John Souvestre 2025-12-22 #

    I think that there is another way to resolve your problem. It's a little more complex but has great benefits.

    Consider that you can store 64 individual bits of information in the space an "int" takes. Since Go wants to allocate those 8 bytes anyway, why not use all 64 of the bits?

    Strip the bottom 6 bits from the int you are using for the map key. Then use those same 6 bits to select the single bit in question. This only take a few basic instructions.

    Now you have no wasted data space at all, and you only need 1/64th the number of hash key! You've reduced you memory requirement dramatically. And, if you ever need to do a sequential access, then it will be a lot quicker.

    I don't know, but perhaps the Go team considered this when making their decision to simplify the code for the new map technique's

    reply

  • Juliane 2025-12-24 #

    So it seems the only slight reason this post gives for not continuing to use empty structs still is "Using empty structs also hurts readability."

    This is not a compelling argument and I think it is also wrong. Using the empty struct removes ambiguity both at the declaration and the use. At the declaration it makes it completely clear, that we don't care for the map values, only for the presence of keys. And at use the "if _, ok := mymap[mykey]; ok" syntax might look a bit more cumbersome than "if mymap[mykey]", but it also tells that this is about presence and not about the actual value, and also it prevents the possible error case where the key is present, but value is false.

    While it is interesting from an academic standpoint that the empty struct no longer wins against bool in terms of memory usage, that was imho never the sole (or probably even main) argument for using the empty struct here. And certainly using bool instead does not win anything really, only very minute smaller code statements, but with added risk for bugs and ambiguity.

    reply