A few months ago, I noticed an issue with the internal structure of the new Go map—specifically regarding empty slots! So I decided to figure out why a map[int64]int64 theoretically appears to be 16 bytes per element: 8 bytes for the key + 8 bytes for the value. But in Go 1.27.1, 1,000,000 elements take up approximately 37.8 MB, which is ~37.8 bytes per element. This made me wonder: where do those extra ~22 MB come from?! 🤔 And if you thought that Go simply adds some large header to each element, that’s not the case at all!
A Go map holding one int64 key and one int64 value takes 192 bytes of heap. A map[int64]int64 with a million entries takes 37.8MB. The napkin math for that second map is eight bytes of key plus eight bytes of value, sixteen bytes an entry, 16MB for the million and the gap between that and what the heap reports is what this whole piece is about. 🤷♂️
It's a follow-up to the goroutine stacks piece and it uses the same method, which boils down to taking something everybody in Go uses without thinking, making a lot of it and forcing a garbage collection to see what the runtime says is still live. The goroutine number held up until the goroutines called something. For maps it doesn't hold up at all. The cost per entry swings between 21 and 44 bytes depending on nothing but how many entries there are, and sets and deletes both behave differently on the current map than most of what's written about them says.
Everything below ran on Go 1.27.1 on an Apple M5 Max and I reran the byte counts on 1.26.4, which gave the same numbers apart from a little run-to-run noise right where tables split. MB means a million bytes throughout, since that's the unit the napkin math uses.
Search for how Go maps work and most of what comes back is about buckets. A bucket holds up to 8 key/value pairs plus a few high bits of each key's hash, a ninth key that lands in a full bucket gets chained onto an overflow bucket, and once buckets average 6.5 entries the whole map doubles. Every bit of that was true, and it still sits in runtime/map.go in Go 1.23. There's a comment in there worth remembering. It says the bucket keeps all its keys together and all its values together because alternating them would need padding for something like map[int64]int8.
Go 1.24 replaced it with a map that doesn't have buckets at all. Go 1.24's release notes list "a new builtin map implementation based on Swiss Tables" among the runtime changes that cut CPU overhead by 2 to 3% on average, and Michael Pratt's post on the Go blog shows microbenchmarks where map operations got up to 60% faster than in 1.23. There aren't any overflow chains in the new map and there's no 6.5 either, so plenty of good writing about Go map memory describes a map that hasn't been the default since February 2025.
Here's the part of the harness that does the measuring. It reads HeapAlloc after a forced GC, fills the maps and reads it again after another one.
Four details keep the numbers honest. runtime.GC() doesn't return until the collection and the sweep after it are done, so HeapAlloc right after it is live memory and not garbage waiting to be swept. Small maps get built many times over (as many copies as fit in two million entries) so a stray runtime allocation can't pass for a per-map cost. And the maps escape into a slice on purpose. A small map that provably never escapes can get its storage on the stack where HeapAlloc wouldn't ever see it. The last one's the KeepAlive(keys) line: a trimmed copy of this function that didn't have it reported 29.8 bytes per entry at a million, because when the call's the last place the keys slice gets used the second GC frees its 8MB and takes it off the total.
One entry is 192 bytes and so are eight. The ninth entry nearly doubles the map and the fifteenth nearly doubles it again. At 896 entries each one costs 20.7 bytes, the closest a map[int64]int64 got to sixteen in any of my runs. Entry 897 pushes it straight to 41.2, and from there it keeps bouncing between the two with 37.0 at a thousand, 29.6 at ten thousand, 23.6 at a hundred thousand and 37.8 at a million. The keys and values don't change between rows. Only the count does.
The new map's entries live in groups. A group is 8 slots plus an 8-byte control word with one byte per slot, and each control byte says whether its slot is empty, deleted or full, with the low 7 bits of the key's hash packed into it when the slot is full. That's what lets a lookup check all eight bytes against the hash in one go and only compare the keys whose bits already match. For map[int64]int64 that's a 16-byte slot and a group of 8 + 816 = 136 bytes.
A map with 8 or fewer entries is one group and nothing else (the source calls this the small map optimization), and the allocator doesn't have a 136-byte size, it rounds up to its 144-byte size class, while the Map header next to it is another 48 bytes. That's the 192.
The ninth entry turns that group into a real table with 16 slots, and from then on a table grows when it's 7/8 full (maxAvgGroupLoad = 7 in group.go, next to a comment saying it's "the same load factor used by Abseil"). Growing means building a new table twice the size and moving every entry into it. Right after a grow the table's only 7/16 full and more than half of it is empty, then it fills back up to 7/8 before the next doubling, and that's the whole shape of the table above: the cost per entry falls as a table fills and jumps every time it doubles, all the way up to entry 897.
Tables don't keep doubling past 1024 slots. That limit is maxTableCapacity in table.go and the comment above it has a TODO that says "Completely made up value", which I think is the most honest line in the runtime. A full table that size splits into two new 1024-slot tables and a small directory in front of them picks the right table from the hash's top bits. The blog post gives the reason: Go wants every insert to have "an upper bound on the amount of growth work it must do", and moving 896 entries is a small fixed amount of work no matter how big the whole map gets.
That's where the size-class rounding shows up again. A 1024-slot table is 128 groups or 17408 bytes and the allocator rounds that up to its 18432-byte class, so a table that's exactly as full as it's allowed to be (896 entries) costs 20.6 bytes per entry before any headers and a table that just split costs twice that. And since the hash spreads keys evenly, every table fills at about the same speed and splits at about the same moment, so the sawtooth doesn't smooth out as the map grows and its teeth just get wider. Around 110 thousand entries the map cost 21.4 bytes per entry and around 120 thousand it cost 39.3, and a million entries sits just past the split at 917,504, so that's the whole reason it comes out at 37.8.
I know this chart looks a bit like a heartbeat monitor... I promise it’s actually Go map memory usage. 😄
So the sixteen bytes are in there. They're the slot. What the napkin math doesn't count is everything around the slot and the biggest part of that is plain emptiness, because in any map past eight entries somewhere between 12.5% and 56% of the slots are empty by design.
Everything so far used int64 keys and int64 values, and since the slot's the thing that repeats, key and value sizes move the whole number. Here's the same million entries with different types.
| Map type | Slot size in bytes | Bytes per entry | |---|---|---| | map[int32]int32 | 8 | 19.5 | | map[int64]int64 | 16 | 37.8 | | map[int32]int64 | 16 | 37.8 | | map[string]int64 | 24 | 55.8 | | map[int64][64]byte | 72 | 167.8 | | map[int64][128]byte | 136 | 301.8 | | map[int64][129]byte | 16, value stored separately | 181.8 |
