DEV Community

Cover image for A Million Go Map Entries Take 38MB, Not 16MB
Nazar Boyko
Nazar Boyko

Posted on

A Million Go Map Entries Take 38MB, Not 16MB

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.

The Map Most Go Articles Describe Was Replaced In Go 1.24

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.

One Entry Costs 192 Bytes, A Million Cost 37.8MB

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.

main.go

func liveHeap() uint64 {
    runtime.GC()
    var ms runtime.MemStats
    runtime.ReadMemStats(&ms)
    return ms.HeapAlloc
}

// bytesPerMap fills `copies` maps with n entries each and returns the live
// heap bytes per map. The keys exist before the first reading, so only the
// maps themselves are counted.
func bytesPerMap[K comparable, V any](n, copies int, keys []K, val V) float64 {
    maps := make([]map[K]V, copies)
    before := liveHeap()
    for c := range maps {
        m := make(map[K]V)
        for i := range n {
            m[keys[i]] = val
        }
        maps[c] = m
    }
    after := liveHeap()
    runtime.KeepAlive(maps)
    runtime.KeepAlive(keys) // or the GC can free the keys and subtract them
    return float64(after-before) / float64(copies)
}
Enter fullscreen mode Exit fullscreen mode

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.

Here's map[int64]int64 at a handful of sizes.

# go1.27.1 darwin/arm64
     entries      bytes/map  bytes/entry
           1            192        192.0
           8            192         24.0
           9            376         41.8
          14            376         26.9
          15            664         44.3
         100           2392         23.9
         896          18519         20.7
         897          36991         41.2
        1000          36991         37.0
       10000         295585         29.6
      100000        2364444         23.6
     1000000       37812804         37.8
    10000000      302642440         30.3
Enter fullscreen mode Exit fullscreen mode

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.

Most Of The Extra Bytes Are Empty Slots

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 + 8*16 = 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.

Line chart of bytes per entry for a Go map[int64]int64 on Go 1.27.1 from 8 to 16 million entries: a sawtooth between about 21 and 44 bytes, far above a dashed napkin-math line at 16 bytes

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.

A 129-Byte Value Costs Less Than A 128-Byte One

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

A slot is the key followed by the value, padded out to the alignment of the bigger one, so map[int32]int64 costs exactly what map[int64]int64 costs because 4 bytes of each of its slots are padding. There's a TODO in table.go that admits it: interleaving keys and values is good for locality "but it comes at the expense of wasted space for some types". That's the same padding the old bucket comment was avoiding.

A string key's 16-byte header goes in the slot (a pointer and a length) and the characters live in their own allocation, and since my keys were built before the first reading the 55.8 is the map alone, so a real map[string]int64 pays that plus whatever its strings cost.

The last two rows are the ones that don't make sense at first. Keys and values bigger than 128 bytes don't go in the slot at all: MapMaxKeyBytes and MapMaxElemBytes are both 128 in internal/abi and anything bigger gets its own allocation with a pointer in the slot. A [129]byte value costs a 16-byte slot plus a 144-byte object and a [128]byte value costs a 136-byte slot, so on paper the inline one should win. Even with one table as full as it can get (896 entries) the two only tie at 164.7 bytes each, since the inline version's groups array is over 32KB and anything that big gets rounded up to whole 8KB pages. At a million entries it's the 128-byte map that's 120 bytes per entry bigger. Turns out that's the empty slots again. An inline value takes all of its bytes in every slot, the empty ones included, while an empty slot in the pointer version holds just 16 bytes of key and pointer.

I wouldn't reshape a struct around the 128-byte line. But it explains how a big map of 100-byte structs ends up so much fatter than expected, since those 100 bytes get paid in every empty slot too, and map[K]*V does by hand what the runtime does past 128 bytes at the price of a pointer the garbage collector has to follow.

A struct{} Set Costs Exactly What A bool Set Costs

The standard advice for a set in Go is map[string]struct{} because struct{} doesn't take any bytes and bool takes one. On Go 1.23 that advice was right, by a little: I built the same harness with Go 1.23.12 (the last release line where the bucket map was the default) and a million-entry map[int64]struct{} came out at 22.3 bytes per entry against 24.6 for map[int64]bool, and that's because the bucket kept its 8 values in their own array where 8 zero-size values take zero bytes.

Here's how sets look on the three layouts I could build, all at a million entries.

Map type Go 1.23.12 (buckets) Go 1.27.1 Go 1.27.1 with mapsplitgroup
map[int64]struct{} 22.3 37.8 21.1
map[int64]bool 24.6 37.8 21.1
map[string]struct{} 40.1 55.9 39.0
map[string]bool 42.3 55.8 39.1
map[int64]int64 40.2 37.8 37.8

On the current default map the two set types cost the same (the string rows wobble by 0.1 between runs, in both directions) and both cost a lot more than they did on 1.23. It's the slot again. The slot's a struct with the key first and the value last. The compiler's rule is that any non-empty struct ending in a zero-size field gets one extra byte of padding, so that a pointer to that last field can't point past the end of the object (that rule goes back to issue 9401 from 2014). Alignment then rounds that one byte up to eight, and a bool is also one byte that gets rounded up to the same eight.

Three weeks before 1.24 shipped Michael Pratt opened issue 71368 about exactly this: "With swissmaps in 1.24, a map[int64]struct{} requires 16 bytes of space per slot, rather than the expected 8 bytes." Someone in the thread asked whether map[X]bool was just as bad and got a one-line answer: "Yes, both now allocate exactly the same amount of memory."

One map size can flatter one layout because 1.23 and 1.27 double at different points, so I reran the sets at a hundred thousand and five million entries too. The int64 set on 1.27.1 was 1.5 to 1.7 times its 1.23 size at all three sizes, while map[int64]int64 and map[string]int64 came out smaller on the new layout at all three (40.2 against 37.8 in the last row). Slots that need padding are where it's gone backwards, and the issue calls a set "the most extreme case" of that.

There's a fix already. Jake Bailey's change lays out a group as all 8 keys followed by all 8 values (KKKKVVVV instead of KVKVKVKV) and it was merged in March as GOEXPERIMENT=mapsplitgroup, but Go 1.27 ships with it switched off, so the last column is 1.27.1 with it switched on. With it on, sets drop just below their 1.23 size and map[int32]int64 goes from 37.8 to 27.9 while map[int64]int64 doesn't move because it never had padding to lose. On August 24 Michael Pratt's own change turned it on by default on the development branch and closed the issue under the Go 1.28 milestone. Even with split groups struct{} and bool still tie, though. Eight bool values fit in the same 8 bytes the padding would've taken.

make(map, n) Makes The Map Faster, Not Smaller

Preallocating is the other standard advice and the natural guess is that it makes the map smaller too, since a map built to size doesn't have to overshoot. Here's the same set of sizes built both ways.

# go1.27.1 darwin/arm64
   entries  grown bytes/ent make(n) bytes/ent
       100             23.9             23.9
      1000             37.0             37.0
     10000             29.6             29.6
     50000             23.6             23.6
    100000             23.6             23.6
    500000             37.8             37.8
   1000000             37.8             37.8
  10000000             30.3             30.3
Enter fullscreen mode Exit fullscreen mode

Same numbers. A size hint asks for enough slots to hold n entries at 7/8 load and rounds the table and directory sizes up to powers of two, and that lands on the same layout growth reaches anyway because all the tables split together. Across the full sweep the two only disagreed right at split points, and not always in the hint's favor: at 897 entries the hinted map was 31.2 bytes per entry against 41.2 for the grown one, but at about 1.9 million it was 39.3 against 37.8.

The hint's payoff is in the work of getting there. Here's the testing.B side of the measurement.

fill_test.go

package main

import (
    "strconv"
    "testing"
)

var sizes = []int{1_000, 100_000, 1_000_000}

var sink map[int64]int64

func BenchmarkFill(b *testing.B) {
    for _, n := range sizes {
        b.Run("grow/"+strconv.Itoa(n), func(b *testing.B) {
            b.ReportAllocs()
            for b.Loop() {
                m := make(map[int64]int64)
                for i := range n {
                    m[int64(i)] = int64(i)
                }
                sink = m
            }
        })
        b.Run("make_n/"+strconv.Itoa(n), func(b *testing.B) {
            b.ReportAllocs()
            for b.Loop() {
                m := make(map[int64]int64, n)
                for i := range n {
                    m[int64(i)] = int64(i)
                }
                sink = m
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode
go test -run '^$' -bench BenchmarkFill -benchmem -count 3
Enter fullscreen mode Exit fullscreen mode
BenchmarkFill/grow/1000-18             40568         27625 ns/op       74456 B/op         22 allocs/op
BenchmarkFill/make_n/1000-18          193857          6219 ns/op       36992 B/op          6 allocs/op
BenchmarkFill/grow/100000-18             478       2472747 ns/op     4729552 B/op        532 allocs/op
BenchmarkFill/make_n/100000-18          1441        838571 ns/op     2364600 B/op        258 allocs/op
BenchmarkFill/grow/1000000-18             25      42785217 ns/op    75605811 B/op       8209 allocs/op
BenchmarkFill/make_n/1000000-18           38      30394810 ns/op    37832752 B/op       4098 allocs/op
Enter fullscreen mode Exit fullscreen mode

That's the first of the three runs, the other two were within about 5%. The B/op column is the cross-check against the heap numbers, and it holds up: a preallocated thousand-entry map allocated 36992 bytes and HeapAlloc said exactly 36992 bytes were live, while at a million entries the preallocated map's live bytes and its B/op differ by 1.5KB out of 37.8MB. A growing map allocates almost exactly twice its final size because each doubling throws the previous table away. On time, the hint made filling a thousand entries 4.4 times faster and a hundred thousand 2.9 times faster, but a million only 1.4 times. I'd guess that at a million most of the time goes into writing 38MB of memory the process has never touched, which both versions pay for equally. I didn't profile it to check though.

Deleting Every Key Gives Back Zero Bytes

Go issue 20135 ("runtime: shrink map as elements are deleted") was opened in April 2017 and it's still open, but everything in its thread was written about the bucket map, so deletes were the part worth checking again on the new one. The steps are in the labels below, and every line was read after a forced GC and measured against the heap from before the map existed.

# go1.27.1 darwin/arm64
filled                             len=1000000  heap=   37798.2 KB
deleted every key                  len=0        heap=   37801.2 KB
refilled with new keys             len=1000000  heap=   37838.2 KB
clear(m)                           len=0        heap=   37838.2 KB
refilled, deleted 99%              len=10000    heap=   37838.3 KB
copied survivors to a new map      len=10000    heap=     301.2 KB
m = nil                            len=0        heap=       5.6 KB
Enter fullscreen mode Exit fullscreen mode

Deleting a million keys didn't free a byte. clear(m) freed nothing either. The map that went from a million entries down to ten thousand still held 37.8MB, nearly 3.8KB for each entry left in it, and the only line that moved at all is the one where the ten thousand survivors went into a fresh map and the old one got dropped.

The source says the same thing and it doesn't need any measuring. Clear walks every table and marks every slot empty and right after that loop sits a comment that just says TODO: shrink directory?, while Delete marks a slot empty or leaves a tombstone if its group is full, and nothing in internal/runtime/maps hands a table back while the map's alive. The old for k := range m { delete(m, k) } loop compiles into the same runtime call as clear(m) too. Brad Fitzpatrick pointed out in the same issue that the compiler rewrites that loop into runtime.mapclear.

Okay, but doesn't the garbage collector clean up after a delete? It cleans up what the deleted entry pointed to, and that's it. Keith Randall spelled out the split in that thread back in 2020: "the space for the keys and values themselves won't be reclaimed, as that space is part of the buckets. Only the things referenced by the keys and values will be collected." Swap buckets for groups and it describes the new map just as well. When a session gets deleted from a map[string]*Session the Session and the key's characters can be collected (if nothing else points at them) but the 24-byte slot stays where it is.

The map doesn't keep growing forever, though. Deleted slots get reused, which the "refilled with new keys" line shows well enough: a million new keys went into the emptied map and the heap moved by 37KB. I also ran a sliding window of a hundred thousand live keys where every insert deletes the oldest key, kept it going for twenty million inserts, and watched the map go from 2.36MB to 4.73MB by the two-million mark and then sit there for the other eighteen million. So a map with steady turnover settles at about twice its freshly built size, and a map that was once big stays as big as it ever got.

Josh Bleecher Snyder's first comment on the issue in 2017 is still the whole workaround: "The only available workaround is to make a new map and copy in elements from the old." It's cheaper than it sounds as long as it isn't done after every single delete. Someone in the thread asked about copying to a new map after O(n) deletes and Keith Randall's reply was "That would work fine."

What I'd Change In Real Code

Sets first. I'd still write map[string]struct{} because it tells the next reader the values mean nothing, but on Go 1.24 through 1.27 it doesn't save a single byte over bool, and honestly I wouldn't flip a GOEXPERIMENT in production just to get the split layout early. When the keys are small integers from a dense range the map is the wrong shape anyway, and the difference isn't small: a bitset for a million possible IDs (make([]uint64, 15625)) was 128KB of heap, the same million as a map[int64]struct{} was 37.8MB, and a plain []int64 indexed by the key took 8.0MB.

I'd preallocate whenever the size is known up front. The map ends up the same size but it gets there 1.4 to 4.4 times faster with half the garbage, and garbage is GC work later.

After a big delete I'd rebuild the map. That means copying what's left into make(map[K]V, len(old)) and dropping the old one, since nothing else gives the memory back.

And I'd measure the maps in the real service before rewriting any of them. A heap profile works, with one detail worth knowing before opening it: for a map made without a size hint, pprof puts the memory on the line that inserts into the map and not on the make line, because the tables get allocated as the map grows. My million-entry set showed up as 36.06MB on its assignment line, so pprof's MB is 1024 times 1024 bytes so that's the same 37.8MB.

The map with one int64 in it is still my favorite number from all of this. Sixteen bytes of entry, 176 bytes of map.

I tried to break all of this down in as much detail as possible and verify the numbers along the way. That said, this goes pretty deep into Go’s runtime and memory layout, so I may have missed something or made a mistake in one of the calculations. 🙄 If you spot anything that looks wrong, I’d genuinely appreciate a correction.


Thanks for reading! English isn't my first language, so I use AI to polish the grammar. Everything else here - the ideas, the code, the opinions - is mine.

Enjoyed this one? Let's stay in touch — I'm on LinkedIn, always happy to chat, swap ideas, or just say hi. 👋

Top comments (0)